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}