hwpforge_smithy_hwpx/
default_styles.rs

1//! Version-aware default style definitions for 한글 (Hangul Word Processor).
2//!
3//! Different versions of 한글 include different numbers of built-in styles.
4//! When encoding an HWPX file, the encoder must inject ALL default styles
5//! in the correct order so that style IDs match what 한글 expects.
6//!
7//! # Critical Ordering Note
8//!
9//! In 한글 Modern (2022+), 개요 8–10 are **inserted** at IDs 9–11, pushing
10//! Classic styles like 쪽 번호 from ID 9 to ID 12. This is NOT an append
11//! operation — the arrays must be stored as complete, ordered, version-specific
12//! lists.
13//!
14//! # Usage
15//!
16//! ```
17//! use hwpforge_smithy_hwpx::default_styles::{HancomStyleSet, DefaultStyleEntry};
18//!
19//! let styles: &[DefaultStyleEntry] = HancomStyleSet::Modern.default_styles();
20//! assert_eq!(styles[0].name, "바탕글");
21//! assert_eq!(styles.len(), 22);
22//! ```
23
24/// A single entry in a 한글 default style list.
25///
26/// Each entry corresponds to one `<hh:style>` element that 한글 expects
27/// to find in `header.xml` at a specific positional ID.
28///
29/// # Shape index references
30///
31/// `char_pr_group` and `para_pr_group` are indices into the default charPr
32/// and paraPr arrays injected at the front of the store by `from_registry_with()`.
33/// Extracted from golden fixture `tests/fixtures/textbox.hwpx` `Contents/header.xml`.
34///
35/// Modern (22 styles) mapping:
36///
37/// ```text
38/// charPr groups (7 total, id 0-6):
39///   0: 함초롬바탕 10pt #000000  (바탕글/본문/개요1-7/캡션)
40///   1: 함초롬돋움 10pt #000000  (쪽 번호)
41///   2: 함초롬돋움  9pt #000000  (머리말)
42///   3: 함초롬바탕  9pt #000000  (각주/미주)
43///   4: 함초롬돋움  9pt #000000  (메모)
44///   5: 함초롬돋움 16pt #2E74B5  (차례 제목)
45///   6: 함초롬돋움 11pt #000000  (차례 1-3)
46///
47/// paraPr groups (20 total, id 0-19):
48///   0:  JUSTIFY left=0      개요8-10 use non-sequential ids (see below)
49///   1:  JUSTIFY left=1500   본문
50///   2:  JUSTIFY left=1000 OUTLINE lv=1  개요 1
51///   3:  JUSTIFY left=2000 OUTLINE lv=2  개요 2
52///   4:  JUSTIFY left=3000 OUTLINE lv=3  개요 3
53///   5:  JUSTIFY left=4000 OUTLINE lv=4  개요 4
54///   6:  JUSTIFY left=5000 OUTLINE lv=5  개요 5
55///   7:  JUSTIFY left=6000 OUTLINE lv=6  개요 6
56///   8:  JUSTIFY left=7000 OUTLINE lv=7  개요 7
57///   9:  JUSTIFY left=0   150% spacing   머리말
58///  10:  JUSTIFY indent=-1310 130%       각주/미주
59///  11:  LEFT    left=0   130%           메모
60///  12:  LEFT    left=0   prev=1200 next=300  차례 제목
61///  13:  LEFT    left=0   next=700            차례 1
62///  14:  LEFT    left=1100 next=700           차례 2
63///  15:  LEFT    left=2200 next=700           차례 3
64///  16:  JUSTIFY left=9000 OUTLINE lv=9  개요 8  (style 9 → paraPr 16)
65///  17:  JUSTIFY left=10000 OUTLINE lv=10 개요 9  (style 10 → paraPr 17)  NOTE: lv=10 maps to OUTLINE lv=9 in XML but stored as id=17
66///  18:  JUSTIFY left=8000 OUTLINE lv=8  개요 10 (style 11 → paraPr 18)  NOTE: lv=8 in XML (level field 7→8)
67///  19:  JUSTIFY left=0   150% next=800  캡션
68/// ```
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct DefaultStyleEntry {
71    /// Korean style name (e.g. `"바탕글"`, `"개요 1"`).
72    pub name: &'static str,
73    /// English style name (e.g. `"Normal"`, `"Outline 1"`).
74    pub eng_name: &'static str,
75    /// Style type: `"PARA"` for paragraph styles, `"CHAR"` for character styles.
76    pub style_type: &'static str,
77    /// Index into the default charPr array (0–6 for Modern).
78    ///
79    /// References the `charPrIDRef` attribute in `<hh:style>` elements.
80    pub char_pr_group: u8,
81    /// Index into the default paraPr array (0–19 for Modern).
82    ///
83    /// References the `paraPrIDRef` attribute in `<hh:style>` elements.
84    pub para_pr_group: u8,
85}
86
87impl DefaultStyleEntry {
88    /// Returns `true` if this is a character style (`"CHAR"`).
89    ///
90    /// Character styles use `nextStyleIDRef=0` (바탕글) instead of
91    /// self-referencing like paragraph styles.
92    pub fn is_char_style(&self) -> bool {
93        self.style_type == "CHAR"
94    }
95}
96
97/// The set of default styles to inject when building an HWPX file.
98///
99/// Different versions of 한글 ship with different built-in style tables.
100/// Choosing the wrong set causes style ID mismatches, which can break
101/// automatic numbering, table-of-contents generation, and other features.
102///
103/// # Variant ordering
104///
105/// Use [`HancomStyleSet::Modern`] (the default) unless you are targeting
106/// files for 한글 2020 or earlier ([`Classic`][HancomStyleSet::Classic])
107/// or 한글 2025+ ([`Latest`][HancomStyleSet::Latest]).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109#[non_exhaustive]
110pub enum HancomStyleSet {
111    /// 18 built-in styles — 한글 2014 through 2020.
112    ///
113    /// 개요 8–10 are absent; 쪽 번호 is at ID 9.
114    Classic,
115    /// 22 built-in styles — 한글 2022 and later.
116    ///
117    /// 개요 8–10 are **inserted** at IDs 9–11, shifting 쪽 번호 to ID 12.
118    /// 캡션 (Caption) style is added at ID 21.
119    ///
120    /// This is the default variant because 한글 2022+ is now the most
121    /// widely deployed version.
122    #[default]
123    Modern,
124    /// 23 built-in styles — 한글 2025 and later.
125    ///
126    /// Same as [`Modern`][HancomStyleSet::Modern] with the addition of
127    /// 줄 번호 (Line Number) at ID 22.
128    Latest,
129}
130
131impl HancomStyleSet {
132    /// Returns the complete ordered default-style array for this version.
133    ///
134    /// The slice index corresponds directly to the `id` attribute that
135    /// must be written to `<hh:style id="…">` in `header.xml`.
136    pub fn default_styles(&self) -> &'static [DefaultStyleEntry] {
137        match self {
138            Self::Classic => &CLASSIC_STYLES,
139            Self::Modern => &MODERN_STYLES,
140            Self::Latest => &LATEST_STYLES,
141        }
142    }
143
144    /// Returns the number of default styles for this version.
145    ///
146    /// Equivalent to `self.default_styles().len()`.
147    pub fn count(&self) -> usize {
148        self.default_styles().len()
149    }
150
151    /// Looks up the style index for a given Korean or English style name.
152    ///
153    /// Returns `None` if no matching default style is found in this version's
154    /// style table. The returned index corresponds directly to the `styleIDRef`
155    /// attribute in HWPX `<hp:p>` elements.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use hwpforge_smithy_hwpx::default_styles::HancomStyleSet;
161    ///
162    /// assert_eq!(HancomStyleSet::Modern.style_id_for_name("개요 1"), Some(2));
163    /// assert_eq!(HancomStyleSet::Modern.style_id_for_name("Outline 1"), Some(2));
164    /// assert_eq!(HancomStyleSet::Modern.style_id_for_name("바탕글"), Some(0));
165    /// assert_eq!(HancomStyleSet::Modern.style_id_for_name("unknown"), None);
166    /// ```
167    pub fn style_id_for_name(&self, name: &str) -> Option<usize> {
168        self.default_styles().iter().position(|entry| entry.name == name || entry.eng_name == name)
169    }
170}
171
172// ── Helper macro ───────────────────────────────────────────────────────────
173
174macro_rules! entry {
175    ($name:expr, $eng:expr, $ty:expr, $cp:expr, $pp:expr) => {
176        DefaultStyleEntry {
177            name: $name,
178            eng_name: $eng,
179            style_type: $ty,
180            char_pr_group: $cp,
181            para_pr_group: $pp,
182        }
183    };
184}
185
186// ── Classic (18 styles, 한글 2014–2020) ───────────────────────────────────
187
188/// Default styles shipped with 한글 2014 through 2020.
189///
190/// The 개요 8–10 (Outline 8–10) styles are absent; 쪽 번호 sits at ID 9.
191const CLASSIC_STYLES: [DefaultStyleEntry; 18] = [
192    entry!("바탕글", "Normal", "PARA", 0, 0),          //  0
193    entry!("본문", "Body", "PARA", 0, 1),              //  1
194    entry!("개요 1", "Outline 1", "PARA", 0, 2),       //  2
195    entry!("개요 2", "Outline 2", "PARA", 0, 3),       //  3
196    entry!("개요 3", "Outline 3", "PARA", 0, 4),       //  4
197    entry!("개요 4", "Outline 4", "PARA", 0, 5),       //  5
198    entry!("개요 5", "Outline 5", "PARA", 0, 6),       //  6
199    entry!("개요 6", "Outline 6", "PARA", 0, 7),       //  7
200    entry!("개요 7", "Outline 7", "PARA", 0, 8),       //  8
201    entry!("쪽 번호", "Page Number", "CHAR", 1, 0),    //  9  (CHAR: paraPr=0 unused)
202    entry!("머리말", "Header", "PARA", 2, 9),          // 10
203    entry!("각주", "Footnote", "PARA", 3, 10),         // 11
204    entry!("미주", "Endnote", "PARA", 3, 10),          // 12
205    entry!("메모", "Memo", "PARA", 4, 11),             // 13
206    entry!("차례 제목", "TOC Heading", "PARA", 5, 12), // 14
207    entry!("차례 1", "TOC 1", "PARA", 6, 13),          // 15
208    entry!("차례 2", "TOC 2", "PARA", 6, 14),          // 16
209    entry!("차례 3", "TOC 3", "PARA", 6, 15),          // 17
210];
211
212// ── Modern (22 styles, 한글 2022+) ─────────────────────────────────────────
213
214/// Default styles shipped with 한글 2022 and later.
215///
216/// 개요 8–10 are **inserted** at IDs 9–11 (not appended), which shifts
217/// all subsequent IDs up by 3 compared to Classic. 쪽 번호 moves from
218/// Classic ID 9 to Modern ID 12.
219///
220/// Verified against golden fixture `tests/fixtures/textbox.hwpx`.
221const MODERN_STYLES: [DefaultStyleEntry; 22] = [
222    entry!("바탕글", "Normal", "PARA", 0, 0), //  0  charPr=0 paraPr=0
223    entry!("본문", "Body", "PARA", 0, 1),     //  1  charPr=0 paraPr=1
224    entry!("개요 1", "Outline 1", "PARA", 0, 2), //  2  charPr=0 paraPr=2
225    entry!("개요 2", "Outline 2", "PARA", 0, 3), //  3  charPr=0 paraPr=3
226    entry!("개요 3", "Outline 3", "PARA", 0, 4), //  4  charPr=0 paraPr=4
227    entry!("개요 4", "Outline 4", "PARA", 0, 5), //  5  charPr=0 paraPr=5
228    entry!("개요 5", "Outline 5", "PARA", 0, 6), //  6  charPr=0 paraPr=6
229    entry!("개요 6", "Outline 6", "PARA", 0, 7), //  7  charPr=0 paraPr=7
230    entry!("개요 7", "Outline 7", "PARA", 0, 8), //  8  charPr=0 paraPr=8
231    entry!("개요 8", "Outline 8", "PARA", 0, 18), //  9  charPr=0 paraPr=18 ← non-sequential!
232    entry!("개요 9", "Outline 9", "PARA", 0, 16), // 10  charPr=0 paraPr=16
233    entry!("개요 10", "Outline 10", "PARA", 0, 17), // 11  charPr=0 paraPr=17
234    entry!("쪽 번호", "Page Number", "CHAR", 1, 0), // 12  charPr=1 paraPr=0 (CHAR: paraPr unused)
235    entry!("머리말", "Header", "PARA", 2, 9), // 13  charPr=2 paraPr=9
236    entry!("각주", "Footnote", "PARA", 3, 10), // 14  charPr=3 paraPr=10
237    entry!("미주", "Endnote", "PARA", 3, 10), // 15  charPr=3 paraPr=10
238    entry!("메모", "Memo", "PARA", 4, 11),    // 16  charPr=4 paraPr=11
239    entry!("차례 제목", "TOC Heading", "PARA", 5, 12), // 17  charPr=5 paraPr=12
240    entry!("차례 1", "TOC 1", "PARA", 6, 13), // 18  charPr=6 paraPr=13
241    entry!("차례 2", "TOC 2", "PARA", 6, 14), // 19  charPr=6 paraPr=14
242    entry!("차례 3", "TOC 3", "PARA", 6, 15), // 20  charPr=6 paraPr=15
243    entry!("캡션", "Caption", "PARA", 0, 19), // 21  charPr=0 paraPr=19
244];
245
246// ── Latest (23 styles, 한글 2025+) ─────────────────────────────────────────
247
248/// Default styles shipped with 한글 2025 and later.
249///
250/// Identical to [`MODERN_STYLES`] with the addition of 줄 번호 (Line Number)
251/// as a character style at ID 22.
252const LATEST_STYLES: [DefaultStyleEntry; 23] = [
253    entry!("바탕글", "Normal", "PARA", 0, 0),          //  0
254    entry!("본문", "Body", "PARA", 0, 1),              //  1
255    entry!("개요 1", "Outline 1", "PARA", 0, 2),       //  2
256    entry!("개요 2", "Outline 2", "PARA", 0, 3),       //  3
257    entry!("개요 3", "Outline 3", "PARA", 0, 4),       //  4
258    entry!("개요 4", "Outline 4", "PARA", 0, 5),       //  5
259    entry!("개요 5", "Outline 5", "PARA", 0, 6),       //  6
260    entry!("개요 6", "Outline 6", "PARA", 0, 7),       //  7
261    entry!("개요 7", "Outline 7", "PARA", 0, 8),       //  8
262    entry!("개요 8", "Outline 8", "PARA", 0, 18),      //  9
263    entry!("개요 9", "Outline 9", "PARA", 0, 16),      // 10
264    entry!("개요 10", "Outline 10", "PARA", 0, 17),    // 11
265    entry!("쪽 번호", "Page Number", "CHAR", 1, 0),    // 12
266    entry!("머리말", "Header", "PARA", 2, 9),          // 13
267    entry!("각주", "Footnote", "PARA", 3, 10),         // 14
268    entry!("미주", "Endnote", "PARA", 3, 10),          // 15
269    entry!("메모", "Memo", "PARA", 4, 11),             // 16
270    entry!("차례 제목", "TOC Heading", "PARA", 5, 12), // 17
271    entry!("차례 1", "TOC 1", "PARA", 6, 13),          // 18
272    entry!("차례 2", "TOC 2", "PARA", 6, 14),          // 19
273    entry!("차례 3", "TOC 3", "PARA", 6, 15),          // 20
274    entry!("캡션", "Caption", "PARA", 0, 19),          // 21
275    entry!("줄 번호", "Line Number", "CHAR", 1, 0),    // 22  ← new in 2025 (same charPr as 쪽 번호)
276];
277
278// ── Tests ───────────────────────────────────────────────────────────────────
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn classic_has_18_styles() {
286        assert_eq!(HancomStyleSet::Classic.count(), 18);
287        assert_eq!(HancomStyleSet::Classic.default_styles().len(), 18);
288    }
289
290    #[test]
291    fn modern_has_22_styles() {
292        assert_eq!(HancomStyleSet::Modern.count(), 22);
293        assert_eq!(HancomStyleSet::Modern.default_styles().len(), 22);
294    }
295
296    #[test]
297    fn latest_has_23_styles() {
298        assert_eq!(HancomStyleSet::Latest.count(), 23);
299        assert_eq!(HancomStyleSet::Latest.default_styles().len(), 23);
300    }
301
302    #[test]
303    fn modern_is_default() {
304        assert_eq!(HancomStyleSet::default(), HancomStyleSet::Modern);
305    }
306
307    #[test]
308    fn all_styles_start_with_batanggeul() {
309        for set in [HancomStyleSet::Classic, HancomStyleSet::Modern, HancomStyleSet::Latest] {
310            let styles = set.default_styles();
311            assert_eq!(styles[0].name, "바탕글");
312            assert_eq!(styles[0].eng_name, "Normal");
313            assert_eq!(styles[0].style_type, "PARA");
314        }
315    }
316
317    #[test]
318    fn modern_outline_8_at_index_9() {
319        // Critical: 한글 inserts 개요 8-10 at positions 9-11, NOT appended
320        let styles = HancomStyleSet::Modern.default_styles();
321        assert_eq!(styles[9].name, "개요 8");
322        assert_eq!(styles[9].style_type, "PARA");
323    }
324
325    #[test]
326    fn classic_page_number_at_index_9() {
327        let styles = HancomStyleSet::Classic.default_styles();
328        assert_eq!(styles[9].name, "쪽 번호");
329        assert_eq!(styles[9].style_type, "CHAR");
330    }
331
332    #[test]
333    fn modern_page_number_at_index_12() {
334        // 쪽 번호 shifts from Classic id=9 to Modern id=12
335        let styles = HancomStyleSet::Modern.default_styles();
336        assert_eq!(styles[12].name, "쪽 번호");
337        assert_eq!(styles[12].style_type, "CHAR");
338    }
339
340    #[test]
341    fn latest_extends_modern_with_line_number() {
342        let modern = HancomStyleSet::Modern.default_styles();
343        let latest = HancomStyleSet::Latest.default_styles();
344        // First 22 entries identical
345        assert_eq!(&latest[..22], modern);
346        // 23rd is 줄 번호
347        assert_eq!(latest[22].name, "줄 번호");
348        assert_eq!(latest[22].style_type, "CHAR");
349    }
350
351    #[test]
352    fn style_id_for_name_korean_outline1() {
353        assert_eq!(HancomStyleSet::Modern.style_id_for_name("개요 1"), Some(2));
354    }
355
356    #[test]
357    fn style_id_for_name_english_outline1() {
358        assert_eq!(HancomStyleSet::Modern.style_id_for_name("Outline 1"), Some(2));
359    }
360
361    #[test]
362    fn style_id_for_name_batanggeul_is_0() {
363        assert_eq!(HancomStyleSet::Modern.style_id_for_name("바탕글"), Some(0));
364        assert_eq!(HancomStyleSet::Modern.style_id_for_name("Normal"), Some(0));
365    }
366
367    #[test]
368    fn style_id_for_name_unknown_returns_none() {
369        assert_eq!(HancomStyleSet::Modern.style_id_for_name("unknown"), None);
370        assert_eq!(HancomStyleSet::Modern.style_id_for_name(""), None);
371    }
372
373    #[test]
374    fn style_id_for_name_classic_vs_modern_differ() {
375        // 쪽 번호 is at 9 in Classic, 12 in Modern
376        assert_eq!(HancomStyleSet::Classic.style_id_for_name("쪽 번호"), Some(9));
377        assert_eq!(HancomStyleSet::Modern.style_id_for_name("쪽 번호"), Some(12));
378    }
379
380    #[test]
381    fn modern_char_pr_groups_in_range() {
382        // All charPr group indices must be within 0..7
383        for entry in HancomStyleSet::Modern.default_styles() {
384            assert!(
385                entry.char_pr_group < 7,
386                "charPr group {} out of range for {}",
387                entry.char_pr_group,
388                entry.name
389            );
390        }
391    }
392
393    #[test]
394    fn modern_para_pr_groups_in_range() {
395        // All paraPr group indices must be within 0..20
396        for entry in HancomStyleSet::Modern.default_styles() {
397            assert!(
398                entry.para_pr_group < 20,
399                "paraPr group {} out of range for {}",
400                entry.para_pr_group,
401                entry.name
402            );
403        }
404    }
405
406    #[test]
407    fn modern_batanggeul_uses_group_0() {
408        let styles = HancomStyleSet::Modern.default_styles();
409        assert_eq!(styles[0].char_pr_group, 0); // 바탕글: charPr=0
410        assert_eq!(styles[0].para_pr_group, 0); // 바탕글: paraPr=0
411    }
412
413    #[test]
414    fn modern_outline8_uses_nonconsecutive_para_pr() {
415        // 개요 8 (id=9) uses paraPr=18, NOT paraPr=9 — non-sequential!
416        let styles = HancomStyleSet::Modern.default_styles();
417        assert_eq!(styles[9].name, "개요 8");
418        assert_eq!(styles[9].para_pr_group, 18);
419        assert_eq!(styles[10].name, "개요 9");
420        assert_eq!(styles[10].para_pr_group, 16);
421        assert_eq!(styles[11].name, "개요 10");
422        assert_eq!(styles[11].para_pr_group, 17);
423    }
424
425    #[test]
426    fn modern_footnote_endnote_share_para_pr() {
427        let styles = HancomStyleSet::Modern.default_styles();
428        let footnote = styles.iter().find(|e| e.name == "각주").unwrap();
429        let endnote = styles.iter().find(|e| e.name == "미주").unwrap();
430        assert_eq!(footnote.para_pr_group, endnote.para_pr_group);
431        assert_eq!(footnote.char_pr_group, endnote.char_pr_group);
432    }
433
434    #[test]
435    fn modern_toc_entries_share_char_pr() {
436        let styles = HancomStyleSet::Modern.default_styles();
437        let toc1 = styles.iter().find(|e| e.name == "차례 1").unwrap();
438        let toc2 = styles.iter().find(|e| e.name == "차례 2").unwrap();
439        let toc3 = styles.iter().find(|e| e.name == "차례 3").unwrap();
440        assert_eq!(toc1.char_pr_group, 6);
441        assert_eq!(toc2.char_pr_group, 6);
442        assert_eq!(toc3.char_pr_group, 6);
443    }
444}