hwpforge_smithy_hwpx/
style_store.rs

1//! HWPX-specific style storage.
2//!
3//! [`HwpxStyleStore`] is the **smithy-hwpx** analogue of Blueprint's
4//! `StyleRegistry`, but much simpler: it stores only what was actually
5//! found in `header.xml`, with zero inheritance logic.
6//!
7//! All fields use Foundation types (`Color`, `HwpUnit`, `Alignment`)
8//! so downstream code never touches raw XML strings.
9
10use hwpforge_blueprint::registry::StyleRegistry;
11use hwpforge_core::{NumberingDef, TabDef};
12use hwpforge_foundation::{
13    Alignment, BorderFillIndex, BreakType, CharShapeIndex, Color, EmbossType, EmphasisType,
14    EngraveType, FontIndex, HeadingType, HwpUnit, LineSpacingType, OutlineType, ParaShapeIndex,
15    ShadowType, StrikeoutShape, UnderlineType, VerticalPosition, WordBreakType,
16};
17
18use crate::default_styles::HancomStyleSet;
19use crate::error::{HwpxError, HwpxResult};
20
21// ── Font ─────────────────────────────────────────────────────────
22
23/// A resolved font from `<hh:fontface>` → `<hh:font>`.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct HwpxFont {
27    /// Original `id` attribute from XML.
28    pub id: u32,
29    /// Face name (e.g. `"함초롬돋움"`, `"Times New Roman"`).
30    pub face_name: String,
31    /// Language group this font belongs to (e.g. `"HANGUL"`, `"LATIN"`).
32    pub lang: String,
33}
34
35impl HwpxFont {
36    /// Creates a new font entry.
37    pub fn new(id: u32, face_name: impl Into<String>, lang: impl Into<String>) -> Self {
38        Self { id, face_name: face_name.into(), lang: lang.into() }
39    }
40}
41
42// ── Per-language font references ─────────────────────────────────
43
44/// Per-language font index references from `<hh:fontRef>`.
45///
46/// Each field is a [`FontIndex`] pointing into the store's font list
47/// for that language group.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct HwpxFontRef {
51    /// Hangul (한글) font index.
52    pub hangul: FontIndex,
53    /// Latin font index.
54    pub latin: FontIndex,
55    /// Hanja (한자) font index.
56    pub hanja: FontIndex,
57    /// Japanese (日本語) font index.
58    pub japanese: FontIndex,
59    /// Other scripts font index.
60    pub other: FontIndex,
61    /// Symbol font index.
62    pub symbol: FontIndex,
63    /// User-defined font index.
64    pub user: FontIndex,
65}
66
67impl Default for HwpxFontRef {
68    fn default() -> Self {
69        let zero = FontIndex::new(0);
70        Self {
71            hangul: zero,
72            latin: zero,
73            hanja: zero,
74            japanese: zero,
75            other: zero,
76            symbol: zero,
77            user: zero,
78        }
79    }
80}
81
82// ── Character Shape ──────────────────────────────────────────────
83
84/// Resolved character properties from `<hh:charPr>`.
85///
86/// All raw XML strings have been converted to Foundation types.
87#[derive(Debug, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct HwpxCharShape {
90    /// Per-language font references.
91    pub font_ref: HwpxFontRef,
92    /// Font height in HwpUnit (height attribute × 1, already HWPUNIT).
93    pub height: HwpUnit,
94    /// Text color (from `textColor` attribute, e.g. `"#000000"`).
95    pub text_color: Color,
96    /// Background shade color (from `shadeColor`, `"none"` → None).
97    pub shade_color: Option<Color>,
98    /// Bold formatting.
99    pub bold: bool,
100    /// Italic formatting.
101    pub italic: bool,
102    /// Underline type (e.g. `None`, `Bottom`).
103    pub underline_type: UnderlineType,
104    /// Underline color (None = inherit text color).
105    pub underline_color: Option<Color>,
106    /// Strikeout shape (e.g. `None`, `Continuous`).
107    pub strikeout_shape: StrikeoutShape,
108    /// Strikeout color (None = inherit text color).
109    pub strikeout_color: Option<Color>,
110    /// Vertical position (Normal/Superscript/Subscript).
111    pub vertical_position: VerticalPosition,
112    /// Text outline type.
113    pub outline_type: OutlineType,
114    /// Drop shadow type.
115    pub shadow_type: ShadowType,
116    /// Emboss effect type.
117    pub emboss_type: EmbossType,
118    /// Engrave effect type.
119    pub engrave_type: EngraveType,
120    /// Emphasis mark type (from `symMark` attribute).
121    pub emphasis: EmphasisType,
122    /// Character width ratio (uniform, from `ratio` child element).
123    pub ratio: i32,
124    /// Inter-character spacing (uniform, from `spacing` child element).
125    pub spacing: i32,
126    /// Relative font size (uniform, from `relSz` child element).
127    pub rel_sz: i32,
128    /// Vertical position offset (uniform, from `offset` child element).
129    pub char_offset: i32,
130    /// Enable kerning (from `useKerning` attribute, 0/1).
131    pub use_kerning: bool,
132    /// Use font space (from `useFontSpace` attribute, 0/1).
133    pub use_font_space: bool,
134    /// Border/fill reference for character border (`borderFillIDRef`).
135    ///
136    /// `None` means use the default value of `2` (한글 default char background).
137    /// Set to `Some(id)` to reference a custom `HwpxBorderFill` entry.
138    pub border_fill_id: Option<u32>,
139}
140
141impl Default for HwpxCharShape {
142    fn default() -> Self {
143        Self {
144            font_ref: HwpxFontRef::default(),
145            height: HwpUnit::new(1000).unwrap(), // 10pt default (한글 compatible)
146            text_color: Color::BLACK,
147            shade_color: None,
148            bold: false,
149            italic: false,
150            underline_type: UnderlineType::None,
151            underline_color: None,
152            strikeout_shape: StrikeoutShape::None,
153            strikeout_color: None,
154            vertical_position: VerticalPosition::Normal,
155            outline_type: OutlineType::None,
156            shadow_type: ShadowType::None,
157            emboss_type: EmbossType::None,
158            engrave_type: EngraveType::None,
159            emphasis: EmphasisType::None,
160            ratio: 100,
161            spacing: 0,
162            rel_sz: 100,
163            char_offset: 0,
164            use_kerning: false,
165            use_font_space: false,
166            border_fill_id: None,
167        }
168    }
169}
170
171// ── Style ────────────────────────────────────────────────────────
172
173/// Resolved style definition from `<hh:style>`.
174///
175/// Stores style metadata like names and references to character/paragraph
176/// properties. This enables full roundtrip of style names like "바탕글",
177/// "본문", etc.
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[non_exhaustive]
180pub struct HwpxStyle {
181    /// Style ID (from `id` attribute).
182    pub id: u32,
183    /// Style type (e.g. `"PARA"`, `"CHAR"`).
184    pub style_type: String,
185    /// Korean style name (e.g. `"바탕글"`).
186    pub name: String,
187    /// English style name (e.g. `"Normal"`).
188    pub eng_name: String,
189    /// Reference to paragraph properties (from `paraPrIDRef`).
190    pub para_pr_id_ref: u32,
191    /// Reference to character properties (from `charPrIDRef`).
192    pub char_pr_id_ref: u32,
193    /// Reference to next style (from `nextStyleIDRef`).
194    pub next_style_id_ref: u32,
195    /// Language ID (from `langID`).
196    pub lang_id: u32,
197}
198
199// ── Paragraph Shape ──────────────────────────────────────────────
200
201/// Resolved paragraph properties from `<hh:paraPr>`.
202#[derive(Debug, Clone, PartialEq, Eq)]
203#[non_exhaustive]
204pub struct HwpxParaShape {
205    /// Horizontal alignment.
206    pub alignment: Alignment,
207    /// Left indent (from `<hc:left value="..."/>`).
208    pub margin_left: HwpUnit,
209    /// Right indent.
210    pub margin_right: HwpUnit,
211    /// Paragraph indent (from `<hc:intent value="..."/>`).
212    pub indent: HwpUnit,
213    /// Space before paragraph (from `<hc:prev value="..."/>`).
214    pub spacing_before: HwpUnit,
215    /// Space after paragraph (from `<hc:next value="..."/>`).
216    pub spacing_after: HwpUnit,
217    /// Line spacing value.
218    pub line_spacing: i32,
219    /// Line spacing type.
220    pub line_spacing_type: LineSpacingType,
221
222    // Advanced paragraph controls (NEW - Phase 6.2)
223    /// Page/column break type before paragraph.
224    pub break_type: BreakType,
225    /// Keep paragraph with next (prevent page break between).
226    pub keep_with_next: bool,
227    /// Keep lines together (prevent page break within paragraph).
228    pub keep_lines_together: bool,
229    /// Widow/orphan control (minimum 2 lines at page boundaries).
230    pub widow_orphan: bool,
231    /// Word-breaking rule for Latin text (default: KeepWord).
232    pub break_latin_word: WordBreakType,
233    /// Word-breaking rule for non-Latin text including Korean (default: KeepWord).
234    pub break_non_latin_word: WordBreakType,
235    /// Border/fill reference (None = no border/fill).
236    pub border_fill_id: Option<BorderFillIndex>,
237    /// Heading type for this paragraph.
238    pub heading_type: HeadingType,
239    /// Heading numbering reference (idRef in heading element, 0 = none).
240    pub heading_id_ref: u32,
241    /// Heading outline level (0 = none, 1-10 for outline levels).
242    pub heading_level: u32,
243    /// Tab property reference (tabPrIDRef, 0 = default).
244    pub tab_pr_id_ref: u32,
245    /// Condense value for tight outline spacing.
246    pub condense: u32,
247}
248
249impl Default for HwpxParaShape {
250    fn default() -> Self {
251        Self {
252            alignment: Alignment::Left,
253            margin_left: HwpUnit::ZERO,
254            margin_right: HwpUnit::ZERO,
255            indent: HwpUnit::ZERO,
256            spacing_before: HwpUnit::ZERO,
257            spacing_after: HwpUnit::ZERO,
258            line_spacing: 160,
259            line_spacing_type: LineSpacingType::Percentage,
260            break_type: BreakType::None,
261            keep_with_next: false,
262            keep_lines_together: false,
263            widow_orphan: true, // Enabled by default in HWPX
264            break_latin_word: WordBreakType::KeepWord,
265            break_non_latin_word: WordBreakType::KeepWord,
266            border_fill_id: None,
267            heading_type: HeadingType::None,
268            heading_id_ref: 0,
269            heading_level: 0,
270            tab_pr_id_ref: 0,
271            condense: 0,
272        }
273    }
274}
275
276// ── Border Fill ──────────────────────────────────────────────────
277
278/// Resolved border/fill definition from `<hh:borderFill>`.
279///
280/// Stores border line styles for all 4 sides plus diagonal borders,
281/// 3D/shadow flags, and optional fill configuration.
282#[derive(Debug, Clone, PartialEq)]
283#[non_exhaustive]
284pub struct HwpxBorderFill {
285    /// Border fill ID (1-based, matching `borderFillIDRef` in charPr/paraPr).
286    pub id: u32,
287    /// Whether 3D border effect is enabled.
288    pub three_d: bool,
289    /// Whether shadow effect is enabled.
290    pub shadow: bool,
291    /// Center line type string (e.g. `"NONE"`).
292    pub center_line: String,
293    /// Left border line.
294    pub left: HwpxBorderLine,
295    /// Right border line.
296    pub right: HwpxBorderLine,
297    /// Top border line.
298    pub top: HwpxBorderLine,
299    /// Bottom border line.
300    pub bottom: HwpxBorderLine,
301    /// Diagonal border line.
302    pub diagonal: HwpxBorderLine,
303    /// Slash diagonal type string.
304    pub slash_type: String,
305    /// Back-slash diagonal type string.
306    pub back_slash_type: String,
307    /// Fill brush configuration (None = no fill / transparent).
308    pub fill: Option<HwpxFill>,
309}
310
311/// A single border line configuration.
312#[derive(Debug, Clone, PartialEq)]
313pub struct HwpxBorderLine {
314    /// Border line type (e.g. `"NONE"`, `"SOLID"`).
315    pub line_type: String,
316    /// Width string (e.g. `"0.1 mm"`).
317    pub width: String,
318    /// Color string (e.g. `"#000000"`).
319    pub color: String,
320}
321
322impl Default for HwpxBorderLine {
323    fn default() -> Self {
324        Self { line_type: "NONE".into(), width: "0.1 mm".into(), color: "#000000".into() }
325    }
326}
327
328/// Fill brush configuration for a [`HwpxBorderFill`].
329#[derive(Debug, Clone, PartialEq)]
330pub enum HwpxFill {
331    /// Solid or hatch fill via `<hc:winBrush>`.
332    WinBrush {
333        /// Face color string (e.g. `"none"`, `"#RRGGBB"`).
334        face_color: String,
335        /// Hatch pattern color string.
336        hatch_color: String,
337        /// Alpha transparency string.
338        alpha: String,
339    },
340}
341
342impl HwpxBorderFill {
343    /// Default border fill id=1: empty borders, no fill (used for page borders).
344    ///
345    /// Matches the first entry of the legacy `BORDER_FILLS_XML` constant.
346    pub fn default_page_border() -> Self {
347        let none_border = HwpxBorderLine::default(); // NONE, 0.1 mm, #000000
348        Self {
349            id: 1,
350            three_d: false,
351            shadow: false,
352            center_line: "NONE".into(),
353            left: none_border.clone(),
354            right: none_border.clone(),
355            top: none_border.clone(),
356            bottom: none_border.clone(),
357            diagonal: HwpxBorderLine { line_type: "SOLID".into(), ..HwpxBorderLine::default() },
358            slash_type: "NONE".into(),
359            back_slash_type: "NONE".into(),
360            fill: None,
361        }
362    }
363
364    /// Default border fill id=2: char background with `winBrush` fill.
365    ///
366    /// This is referenced by every `<hh:charPr borderFillIDRef="2">`.
367    /// Matches the second entry of the legacy `BORDER_FILLS_XML` constant.
368    pub fn default_char_background() -> Self {
369        let none_border = HwpxBorderLine::default();
370        Self {
371            id: 2,
372            three_d: false,
373            shadow: false,
374            center_line: "NONE".into(),
375            left: none_border.clone(),
376            right: none_border.clone(),
377            top: none_border.clone(),
378            bottom: none_border.clone(),
379            diagonal: HwpxBorderLine { line_type: "SOLID".into(), ..HwpxBorderLine::default() },
380            slash_type: "NONE".into(),
381            back_slash_type: "NONE".into(),
382            fill: Some(HwpxFill::WinBrush {
383                face_color: "none".into(),
384                hatch_color: "#FF000000".into(),
385                alpha: "0".into(),
386            }),
387        }
388    }
389
390    /// Default border fill id=3: SOLID borders on all 4 sides (used for table cells).
391    ///
392    /// Matches the third entry of the legacy `BORDER_FILLS_XML` constant.
393    pub fn default_table_border() -> Self {
394        let solid_border = HwpxBorderLine {
395            line_type: "SOLID".into(),
396            width: "0.12 mm".into(),
397            color: "#000000".into(),
398        };
399        Self {
400            id: 3,
401            three_d: false,
402            shadow: false,
403            center_line: "NONE".into(),
404            left: solid_border.clone(),
405            right: solid_border.clone(),
406            top: solid_border.clone(),
407            bottom: solid_border.clone(),
408            diagonal: HwpxBorderLine { line_type: "SOLID".into(), ..HwpxBorderLine::default() },
409            slash_type: "NONE".into(),
410            back_slash_type: "NONE".into(),
411            fill: None,
412        }
413    }
414}
415
416// ── Default shape definitions ────────────────────────────────────
417
418/// Returns the 7 default character shapes for Modern (한글 2022+).
419///
420/// Extracted from golden fixture `tests/fixtures/textbox.hwpx` `Contents/header.xml`.
421///
422/// ```text
423/// id=0: 함초롬바탕 10pt #000000  (바탕글/본문/개요1-7/캡션)
424/// id=1: 함초롬돋움 10pt #000000  (쪽 번호)
425/// id=2: 함초롬돋움  9pt #000000  (머리말)
426/// id=3: 함초롬바탕  9pt #000000  (각주/미주)
427/// id=4: 함초롬돋움  9pt #000000  (메모)
428/// id=5: 함초롬돋움 16pt #2E74B5  (차례 제목)
429/// id=6: 함초롬돋움 11pt #000000  (차례 1-3)
430/// ```
431///
432/// Font indices: 0 = 함초롬돋움, 1 = 함초롬바탕 (as in fixture font table).
433pub(crate) fn default_char_shapes_modern() -> [HwpxCharShape; 7] {
434    let batang = FontIndex::new(1); // 함초롬바탕
435    let dotum = FontIndex::new(0); // 함초롬돋움
436
437    let batang_ref = HwpxFontRef {
438        hangul: batang,
439        latin: batang,
440        hanja: batang,
441        japanese: batang,
442        other: batang,
443        symbol: batang,
444        user: batang,
445    };
446    let dotum_ref = HwpxFontRef {
447        hangul: dotum,
448        latin: dotum,
449        hanja: dotum,
450        japanese: dotum,
451        other: dotum,
452        symbol: dotum,
453        user: dotum,
454    };
455
456    let base = HwpxCharShape {
457        font_ref: batang_ref,
458        height: HwpUnit::new(1000).unwrap(), // 10pt
459        text_color: Color::BLACK,
460        shade_color: None,
461        bold: false,
462        italic: false,
463        underline_type: UnderlineType::None,
464        underline_color: None,
465        strikeout_shape: StrikeoutShape::None,
466        strikeout_color: None,
467        vertical_position: VerticalPosition::Normal,
468        outline_type: OutlineType::None,
469        shadow_type: ShadowType::None,
470        emboss_type: EmbossType::None,
471        engrave_type: EngraveType::None,
472        emphasis: EmphasisType::None,
473        ratio: 100,
474        spacing: 0,
475        rel_sz: 100,
476        char_offset: 0,
477        use_kerning: false,
478        use_font_space: false,
479        border_fill_id: None,
480    };
481
482    [
483        // id=0: 함초롬바탕 10pt black (바탕글/본문/개요1-7/캡션)
484        base.clone(),
485        // id=1: 함초롬돋움 10pt black (쪽 번호)
486        HwpxCharShape { font_ref: dotum_ref, ..base.clone() },
487        // id=2: 함초롬돋움 9pt black (머리말)
488        HwpxCharShape { font_ref: dotum_ref, height: HwpUnit::new(900).unwrap(), ..base.clone() },
489        // id=3: 함초롬바탕 9pt black (각주/미주)
490        HwpxCharShape { height: HwpUnit::new(900).unwrap(), ..base.clone() },
491        // id=4: 함초롬돋움 9pt black (메모)
492        HwpxCharShape { font_ref: dotum_ref, height: HwpUnit::new(900).unwrap(), ..base.clone() },
493        // id=5: 함초롬돋움 16pt #2E74B5 (차례 제목)
494        HwpxCharShape {
495            font_ref: dotum_ref,
496            height: HwpUnit::new(1600).unwrap(),
497            text_color: Color::from_rgb(0x2E, 0x74, 0xB5),
498            ..base.clone()
499        },
500        // id=6: 함초롬돋움 11pt black (차례 1-3)
501        HwpxCharShape { font_ref: dotum_ref, height: HwpUnit::new(1100).unwrap(), ..base },
502    ]
503}
504
505/// Returns the 20 default paragraph shapes for Modern (한글 2022+).
506///
507/// Extracted from golden fixture `tests/fixtures/textbox.hwpx` `Contents/header.xml`.
508///
509/// Values are in HWPUNIT (1pt = 100 HWPUNIT).
510pub(crate) fn default_para_shapes_modern() -> [HwpxParaShape; 20] {
511    let justify = Alignment::Justify;
512    let left = Alignment::Left;
513
514    // Base: JUSTIFY, no margins/indent, 160% line spacing, no widow/orphan
515    let base = HwpxParaShape {
516        alignment: justify,
517        margin_left: HwpUnit::ZERO,
518        margin_right: HwpUnit::ZERO,
519        indent: HwpUnit::ZERO,
520        spacing_before: HwpUnit::ZERO,
521        spacing_after: HwpUnit::ZERO,
522        line_spacing: 160,
523        line_spacing_type: LineSpacingType::Percentage,
524        break_type: BreakType::None,
525        keep_with_next: false,
526        keep_lines_together: false,
527        widow_orphan: false,
528        break_latin_word: WordBreakType::KeepWord,
529        break_non_latin_word: WordBreakType::KeepWord,
530        border_fill_id: None,
531        heading_type: HeadingType::None,
532        heading_id_ref: 0,
533        heading_level: 0,
534        tab_pr_id_ref: 0,
535        condense: 0,
536    };
537
538    [
539        //  0: 바탕글 — JUSTIFY left=0 160%
540        base.clone(),
541        //  1: 본문 — JUSTIFY left=1500 160%
542        HwpxParaShape { margin_left: HwpUnit::new(1500).unwrap(), ..base.clone() },
543        //  2: 개요 1 — JUSTIFY left=1000 160% OUTLINE level=1
544        HwpxParaShape {
545            margin_left: HwpUnit::new(1000).unwrap(),
546            heading_type: HeadingType::Outline,
547            heading_id_ref: 1,
548            heading_level: 1,
549            tab_pr_id_ref: 1,
550            condense: 20,
551            ..base.clone()
552        },
553        //  3: 개요 2 — JUSTIFY left=2000 160% OUTLINE level=2
554        HwpxParaShape {
555            margin_left: HwpUnit::new(2000).unwrap(),
556            heading_type: HeadingType::Outline,
557            heading_id_ref: 1,
558            heading_level: 2,
559            tab_pr_id_ref: 1,
560            condense: 20,
561            ..base.clone()
562        },
563        //  4: 개요 3 — JUSTIFY left=3000 160% OUTLINE level=3
564        HwpxParaShape {
565            margin_left: HwpUnit::new(3000).unwrap(),
566            heading_type: HeadingType::Outline,
567            heading_id_ref: 1,
568            heading_level: 3,
569            tab_pr_id_ref: 1,
570            condense: 20,
571            ..base.clone()
572        },
573        //  5: 개요 4 — JUSTIFY left=4000 160% OUTLINE level=4
574        HwpxParaShape {
575            margin_left: HwpUnit::new(4000).unwrap(),
576            heading_type: HeadingType::Outline,
577            heading_id_ref: 1,
578            heading_level: 4,
579            tab_pr_id_ref: 1,
580            condense: 20,
581            ..base.clone()
582        },
583        //  6: 개요 5 — JUSTIFY left=5000 160% OUTLINE level=5
584        HwpxParaShape {
585            margin_left: HwpUnit::new(5000).unwrap(),
586            heading_type: HeadingType::Outline,
587            heading_id_ref: 1,
588            heading_level: 5,
589            tab_pr_id_ref: 1,
590            condense: 20,
591            ..base.clone()
592        },
593        //  7: 개요 6 — JUSTIFY left=6000 160% OUTLINE level=6
594        HwpxParaShape {
595            margin_left: HwpUnit::new(6000).unwrap(),
596            heading_type: HeadingType::Outline,
597            heading_id_ref: 1,
598            heading_level: 6,
599            tab_pr_id_ref: 1,
600            condense: 20,
601            ..base.clone()
602        },
603        //  8: 개요 7 — JUSTIFY left=7000 160% OUTLINE level=7
604        HwpxParaShape {
605            margin_left: HwpUnit::new(7000).unwrap(),
606            heading_type: HeadingType::Outline,
607            heading_id_ref: 1,
608            heading_level: 7,
609            tab_pr_id_ref: 1,
610            condense: 20,
611            ..base.clone()
612        },
613        //  9: 머리말 — JUSTIFY left=0 150%
614        HwpxParaShape { line_spacing: 150, ..base.clone() },
615        // 10: 각주/미주 — JUSTIFY indent=-1310 130%
616        HwpxParaShape { indent: HwpUnit::new(-1310).unwrap(), line_spacing: 130, ..base.clone() },
617        // 11: 메모 — LEFT left=0 130%
618        HwpxParaShape { alignment: left, line_spacing: 130, ..base.clone() },
619        // 12: 차례 제목 — LEFT left=0 prev=1200 next=300 160%
620        HwpxParaShape {
621            alignment: left,
622            spacing_before: HwpUnit::new(1200).unwrap(),
623            spacing_after: HwpUnit::new(300).unwrap(),
624            ..base.clone()
625        },
626        // 13: 차례 1 — LEFT left=0 next=700 160%
627        HwpxParaShape {
628            alignment: left,
629            spacing_after: HwpUnit::new(700).unwrap(),
630            ..base.clone()
631        },
632        // 14: 차례 2 — LEFT left=1100 next=700 160%
633        HwpxParaShape {
634            alignment: left,
635            margin_left: HwpUnit::new(1100).unwrap(),
636            spacing_after: HwpUnit::new(700).unwrap(),
637            ..base.clone()
638        },
639        // 15: 차례 3 — LEFT left=2200 next=700 160%
640        HwpxParaShape {
641            alignment: left,
642            margin_left: HwpUnit::new(2200).unwrap(),
643            spacing_after: HwpUnit::new(700).unwrap(),
644            ..base.clone()
645        },
646        // 16: 개요 9 (style 10→paraPr 16) — JUSTIFY left=9000 160% OUTLINE level=9
647        HwpxParaShape {
648            margin_left: HwpUnit::new(9000).unwrap(),
649            heading_type: HeadingType::Outline,
650            heading_id_ref: 1,
651            heading_level: 9,
652            tab_pr_id_ref: 1,
653            condense: 20,
654            ..base.clone()
655        },
656        // 17: 개요 10 (style 11→paraPr 17) — JUSTIFY left=10000 160% OUTLINE level=10
657        HwpxParaShape {
658            margin_left: HwpUnit::new(10000).unwrap(),
659            heading_type: HeadingType::Outline,
660            heading_id_ref: 1,
661            heading_level: 10,
662            tab_pr_id_ref: 1,
663            condense: 20,
664            ..base.clone()
665        },
666        // 18: 개요 8 (style 9→paraPr 18) — JUSTIFY left=8000 160% OUTLINE level=8
667        HwpxParaShape {
668            margin_left: HwpUnit::new(8000).unwrap(),
669            heading_type: HeadingType::Outline,
670            heading_id_ref: 1,
671            heading_level: 8,
672            tab_pr_id_ref: 1,
673            condense: 20,
674            ..base.clone()
675        },
676        // 19: 캡션 — JUSTIFY left=0 next=800 150%
677        HwpxParaShape { line_spacing: 150, spacing_after: HwpUnit::new(800).unwrap(), ..base },
678    ]
679}
680
681// ── Style Store ──────────────────────────────────────────────────
682
683/// HWPX-specific style storage populated from `header.xml`.
684///
685/// Unlike Blueprint's `StyleRegistry`, this has no inheritance or
686/// template merging — it holds exactly what was parsed from the file.
687///
688/// # Index Safety
689///
690/// All accessors return `HwpxResult<&T>` to guard against invalid
691/// indices from malformed HWPX files.
692///
693/// # Examples
694///
695/// ```
696/// use hwpforge_smithy_hwpx::HwpxStyleStore;
697/// use hwpforge_foundation::CharShapeIndex;
698///
699/// let store = HwpxStyleStore::new();
700/// assert!(store.char_shape(CharShapeIndex::new(0)).is_err());
701/// ```
702#[derive(Debug, Clone, Default)]
703pub struct HwpxStyleStore {
704    /// The 한글 version style set used when injecting default styles.
705    style_set: HancomStyleSet,
706    fonts: Vec<HwpxFont>,
707    char_shapes: Vec<HwpxCharShape>,
708    para_shapes: Vec<HwpxParaShape>,
709    styles: Vec<HwpxStyle>,
710    border_fills: Vec<HwpxBorderFill>,
711    numberings: Vec<NumberingDef>,
712    tabs: Vec<TabDef>,
713}
714
715impl HwpxStyleStore {
716    /// Creates an empty store.
717    pub fn new() -> Self {
718        Self::default()
719    }
720
721    /// Creates a new style store with the given font registered for all 7 language groups
722    /// (HANGUL, LATIN, HANJA, JAPANESE, OTHER, SYMBOL, USER).
723    ///
724    /// This eliminates the common boilerplate of manually pushing fonts for each language.
725    ///
726    /// # Examples
727    ///
728    /// ```
729    /// use hwpforge_smithy_hwpx::style_store::HwpxStyleStore;
730    ///
731    /// let store = HwpxStyleStore::with_default_fonts("함초롬돋움");
732    /// assert_eq!(store.font_count(), 7);
733    /// ```
734    pub fn with_default_fonts(font_name: &str) -> Self {
735        let mut store: Self = Self::new();
736        let langs: [&str; 7] = ["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
737        for (idx, &lang) in langs.iter().enumerate() {
738            store.push_font(HwpxFont::new(idx as u32, font_name, lang));
739        }
740        store
741    }
742
743    /// Returns the style set used by this store.
744    pub fn style_set(&self) -> HancomStyleSet {
745        self.style_set
746    }
747
748    /// Creates a store from a Blueprint [`StyleRegistry`] using the default
749    /// style set ([`HancomStyleSet::Modern`]).
750    ///
751    /// This is the **bridge** that lets the MD → Core → HWPX pipeline
752    /// carry resolved styles all the way through to the HWPX encoder.
753    ///
754    /// To target a specific 한글 version, use [`from_registry_with`][Self::from_registry_with].
755    pub fn from_registry(registry: &StyleRegistry) -> Self {
756        Self::from_registry_with(registry, HancomStyleSet::default())
757    }
758
759    /// Creates a store from a Blueprint [`StyleRegistry`] with a specific style set.
760    ///
761    /// The `style_set` controls which default styles are injected:
762    /// - [`Classic`][HancomStyleSet::Classic] — 18 styles (한글 2014–2020)
763    /// - [`Modern`][HancomStyleSet::Modern] — 22 styles (한글 2022+)
764    /// - [`Latest`][HancomStyleSet::Latest] — 23 styles (한글 2025+)
765    ///
766    /// Mapping:
767    /// - `registry.fonts` → [`HwpxFont`] (assigned to HANGUL group)
768    /// - `registry.char_shapes` → [`HwpxCharShape`] (font ref mirrors same index for all lang groups)
769    /// - `registry.para_shapes` → [`HwpxParaShape`]
770    /// - `registry.style_entries` → [`HwpxStyle`] (PARA type, Korean langID)
771    pub fn from_registry_with(registry: &StyleRegistry, style_set: HancomStyleSet) -> Self {
772        let mut store = Self { style_set, ..Self::default() };
773
774        // Step 1: Ensure 한글-compatible fonts exist
775        // If registry has no fonts, inject default Korean fonts
776        let has_fonts = !registry.fonts.is_empty();
777        let default_font = if has_fonts {
778            registry.fonts[0].as_str()
779        } else {
780            "함초롬바탕" // Fallback if no fonts in registry
781        };
782
783        // Fonts: FontId → HwpxFont (mirrored across all 7 language groups)
784        // 한글 expects identical font entries for each language group.
785        const FONT_LANGS: &[&str] =
786            &["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
787
788        if has_fonts {
789            for &lang in FONT_LANGS {
790                for (i, font_id) in registry.fonts.iter().enumerate() {
791                    store.push_font(HwpxFont {
792                        id: i as u32,
793                        face_name: font_id.as_str().to_string(),
794                        lang: lang.to_string(),
795                    });
796                }
797            }
798        } else {
799            // No fonts in registry - inject minimal default
800            for &lang in FONT_LANGS {
801                store.push_font(HwpxFont {
802                    id: 0,
803                    face_name: default_font.to_string(),
804                    lang: lang.to_string(),
805                });
806            }
807        }
808
809        // Step 2: Inject 7 default charShapes and 20 default paraShapes (Modern).
810        //
811        // These MUST come first so that default styles can reference them by
812        // group index (char_pr_group / para_pr_group from DefaultStyleEntry).
813        // User shapes are pushed after and start at offset 7 / 20.
814        //
815        // Classic and Latest share the same shape definitions (only the style
816        // table and its charPr/paraPr references differ).
817        for cs in default_char_shapes_modern() {
818            store.push_char_shape(cs);
819        }
820        for ps in default_para_shapes_modern() {
821            store.push_para_shape(ps);
822        }
823
824        // Offsets for user-defined shapes (placed after the 7+20 defaults).
825        let char_shape_offset = store.char_shape_count(); // 7
826        let para_shape_offset = store.para_shape_count(); // 20
827
828        // Step 3: Push user charShapes from Blueprint (indices start at offset).
829        for cs in &registry.char_shapes {
830            let font_idx = registry
831                .fonts
832                .iter()
833                .position(|f| f.as_str() == cs.font)
834                .map(FontIndex::new)
835                .unwrap_or(FontIndex::new(0));
836            let font_ref = HwpxFontRef {
837                hangul: font_idx,
838                latin: font_idx,
839                hanja: font_idx,
840                japanese: font_idx,
841                other: font_idx,
842                symbol: font_idx,
843                user: font_idx,
844            };
845            store.push_char_shape(HwpxCharShape {
846                font_ref,
847                height: cs.size,
848                text_color: cs.color,
849                shade_color: cs.shade_color,
850                bold: cs.bold,
851                italic: cs.italic,
852                underline_type: cs.underline_type,
853                underline_color: cs.underline_color,
854                strikeout_shape: cs.strikeout_shape,
855                strikeout_color: cs.strikeout_color,
856                vertical_position: cs.vertical_position,
857                outline_type: cs.outline,
858                shadow_type: cs.shadow,
859                emboss_type: cs.emboss,
860                engrave_type: cs.engrave,
861                emphasis: cs.emphasis,
862                ratio: cs.ratio,
863                spacing: cs.spacing,
864                rel_sz: cs.rel_sz,
865                char_offset: cs.offset,
866                use_kerning: cs.use_kerning,
867                use_font_space: cs.use_font_space,
868                border_fill_id: cs.char_border_fill_id,
869            });
870        }
871
872        // Step 4: Push user paraShapes from Blueprint (indices start at offset).
873        for ps in &registry.para_shapes {
874            store.push_para_shape(HwpxParaShape {
875                alignment: ps.alignment,
876                margin_left: ps.indent_left,
877                margin_right: ps.indent_right,
878                indent: ps.indent_first_line,
879                spacing_before: ps.space_before,
880                spacing_after: ps.space_after,
881                line_spacing: ps.line_spacing_value.round() as i32,
882                line_spacing_type: ps.line_spacing_type,
883                break_type: ps.break_type,
884                keep_with_next: ps.keep_with_next,
885                keep_lines_together: ps.keep_lines_together,
886                widow_orphan: ps.widow_orphan,
887                break_latin_word: WordBreakType::KeepWord,
888                break_non_latin_word: WordBreakType::KeepWord,
889                border_fill_id: ps.border_fill_id,
890                heading_type: HeadingType::None,
891                heading_id_ref: 0,
892                heading_level: 0,
893                tab_pr_id_ref: 0,
894                condense: 0,
895            });
896        }
897
898        // Step 4.5: Inject 3 default border fills for backward compatibility.
899        // These must always be present; user-defined fills get id=4+.
900        store.push_border_fill(HwpxBorderFill::default_page_border()); // id=1
901        store.push_border_fill(HwpxBorderFill::default_char_background()); // id=2
902        store.push_border_fill(HwpxBorderFill::default_table_border()); // id=3
903
904        // Step 5: Inject default styles with per-style charPr/paraPr group refs.
905        // The group indices are verified against golden fixture textbox.hwpx.
906        let defaults = store.style_set.default_styles();
907        for (idx, entry) in defaults.iter().enumerate() {
908            let next_style_id_ref = if entry.is_char_style() { 0 } else { idx as u32 };
909            store.push_style(HwpxStyle {
910                id: idx as u32,
911                style_type: entry.style_type.to_string(),
912                name: entry.name.to_string(),
913                eng_name: entry.eng_name.to_string(),
914                para_pr_id_ref: entry.para_pr_group as u32,
915                char_pr_id_ref: entry.char_pr_group as u32,
916                next_style_id_ref,
917                lang_id: 1042, // Korean
918            });
919        }
920
921        // Step 6: Add user's styles from registry (starting after defaults).
922        // User charPr/paraPr refs are offset-adjusted so they point at the
923        // user shapes in the store (which start after the 7/20 defaults).
924        let style_offset = defaults.len();
925        for (i, (name, entry)) in registry.style_entries.iter().enumerate() {
926            store.push_style(HwpxStyle {
927                id: (style_offset + i) as u32,
928                style_type: "PARA".to_string(),
929                name: name.clone(),
930                eng_name: name.clone(),
931                para_pr_id_ref: (entry.para_shape_id.get() + para_shape_offset) as u32,
932                char_pr_id_ref: (entry.char_shape_id.get() + char_shape_offset) as u32,
933                next_style_id_ref: 0,
934                lang_id: 1042, // Korean
935            });
936        }
937
938        store
939    }
940
941    // ── Fonts ────────────────────────────────────────────────────
942
943    /// Adds a font and returns its index.
944    pub fn push_font(&mut self, font: HwpxFont) -> FontIndex {
945        let idx = FontIndex::new(self.fonts.len());
946        self.fonts.push(font);
947        idx
948    }
949
950    /// Returns the font at `index`.
951    pub fn font(&self, index: FontIndex) -> HwpxResult<&HwpxFont> {
952        self.fonts.get(index.get()).ok_or_else(|| HwpxError::IndexOutOfBounds {
953            kind: "font",
954            index: index.get() as u32,
955            max: self.fonts.len() as u32,
956        })
957    }
958
959    /// Returns the number of fonts.
960    pub fn font_count(&self) -> usize {
961        self.fonts.len()
962    }
963
964    // ── Character Shapes ─────────────────────────────────────────
965
966    /// Adds a char shape and returns its index.
967    pub fn push_char_shape(&mut self, shape: HwpxCharShape) -> CharShapeIndex {
968        let idx = CharShapeIndex::new(self.char_shapes.len());
969        self.char_shapes.push(shape);
970        idx
971    }
972
973    /// Returns the char shape at `index`.
974    pub fn char_shape(&self, index: CharShapeIndex) -> HwpxResult<&HwpxCharShape> {
975        self.char_shapes.get(index.get()).ok_or_else(|| HwpxError::IndexOutOfBounds {
976            kind: "char_shape",
977            index: index.get() as u32,
978            max: self.char_shapes.len() as u32,
979        })
980    }
981
982    /// Returns the number of char shapes.
983    pub fn char_shape_count(&self) -> usize {
984        self.char_shapes.len()
985    }
986
987    // ── Paragraph Shapes ─────────────────────────────────────────
988
989    /// Adds a para shape and returns its index.
990    pub fn push_para_shape(&mut self, shape: HwpxParaShape) -> ParaShapeIndex {
991        let idx = ParaShapeIndex::new(self.para_shapes.len());
992        self.para_shapes.push(shape);
993        idx
994    }
995
996    /// Returns the para shape at `index`.
997    pub fn para_shape(&self, index: ParaShapeIndex) -> HwpxResult<&HwpxParaShape> {
998        self.para_shapes.get(index.get()).ok_or_else(|| HwpxError::IndexOutOfBounds {
999            kind: "para_shape",
1000            index: index.get() as u32,
1001            max: self.para_shapes.len() as u32,
1002        })
1003    }
1004
1005    /// Returns the number of para shapes.
1006    pub fn para_shape_count(&self) -> usize {
1007        self.para_shapes.len()
1008    }
1009
1010    // ── Iterators ────────────────────────────────────────────────
1011
1012    /// Returns an iterator over all fonts in the store.
1013    pub fn iter_fonts(&self) -> impl Iterator<Item = &HwpxFont> {
1014        self.fonts.iter()
1015    }
1016
1017    /// Returns an iterator over all character shapes in the store.
1018    pub fn iter_char_shapes(&self) -> impl Iterator<Item = &HwpxCharShape> {
1019        self.char_shapes.iter()
1020    }
1021
1022    /// Returns an iterator over all paragraph shapes in the store.
1023    pub fn iter_para_shapes(&self) -> impl Iterator<Item = &HwpxParaShape> {
1024        self.para_shapes.iter()
1025    }
1026
1027    // ── Styles ───────────────────────────────────────────────────
1028
1029    /// Adds a style definition.
1030    pub fn push_style(&mut self, style: HwpxStyle) {
1031        self.styles.push(style);
1032    }
1033
1034    /// Returns the style at `index`.
1035    pub fn style(&self, index: usize) -> HwpxResult<&HwpxStyle> {
1036        self.styles.get(index).ok_or(HwpxError::IndexOutOfBounds {
1037            kind: "style",
1038            index: index as u32,
1039            max: self.styles.len() as u32,
1040        })
1041    }
1042
1043    /// Returns the number of styles.
1044    pub fn style_count(&self) -> usize {
1045        self.styles.len()
1046    }
1047
1048    /// Returns an iterator over all styles in the store.
1049    pub fn iter_styles(&self) -> impl Iterator<Item = &HwpxStyle> {
1050        self.styles.iter()
1051    }
1052
1053    // ── Border Fills ─────────────────────────────────────────────
1054
1055    /// Adds a border fill to the store and returns its 1-based ID.
1056    ///
1057    /// Border fill IDs in HWPX are 1-based (unlike other indices which are 0-based).
1058    pub fn push_border_fill(&mut self, bf: HwpxBorderFill) -> u32 {
1059        let id = bf.id;
1060        self.border_fills.push(bf);
1061        id
1062    }
1063
1064    /// Returns the border fill with the given 1-based ID.
1065    ///
1066    /// # Errors
1067    ///
1068    /// Returns [`HwpxError::IndexOutOfBounds`] if no border fill with that ID exists.
1069    pub fn border_fill(&self, id: u32) -> HwpxResult<&HwpxBorderFill> {
1070        self.border_fills.iter().find(|bf| bf.id == id).ok_or(HwpxError::IndexOutOfBounds {
1071            kind: "border_fill",
1072            index: id,
1073            max: self.border_fills.len() as u32,
1074        })
1075    }
1076
1077    /// Returns the number of border fills in the store.
1078    pub fn border_fill_count(&self) -> usize {
1079        self.border_fills.len()
1080    }
1081
1082    /// Returns an iterator over all border fills in the store.
1083    pub fn iter_border_fills(&self) -> impl Iterator<Item = &HwpxBorderFill> {
1084        self.border_fills.iter()
1085    }
1086
1087    /// Adds a numbering definition to the store.
1088    pub fn push_numbering(&mut self, ndef: NumberingDef) {
1089        self.numberings.push(ndef);
1090    }
1091
1092    /// Adds a tab property definition to the store.
1093    pub fn push_tab(&mut self, tab: TabDef) {
1094        self.tabs.push(tab);
1095    }
1096
1097    /// Returns the number of numbering definitions in the store.
1098    pub fn numbering_count(&self) -> u32 {
1099        self.numberings.len() as u32
1100    }
1101
1102    /// Returns the number of tab property definitions in the store.
1103    pub fn tab_count(&self) -> u32 {
1104        self.tabs.len() as u32
1105    }
1106
1107    /// Returns an iterator over all numbering definitions in the store.
1108    pub fn iter_numberings(&self) -> impl Iterator<Item = &NumberingDef> {
1109        self.numberings.iter()
1110    }
1111
1112    /// Returns an iterator over all tab property definitions in the store.
1113    pub fn iter_tabs(&self) -> impl Iterator<Item = &TabDef> {
1114        self.tabs.iter()
1115    }
1116}
1117
1118// ── Thread safety assertions ─────────────────────────────────────
1119
1120#[allow(dead_code)]
1121const _: () = {
1122    fn assert_send<T: Send>() {}
1123    fn assert_sync<T: Sync>() {}
1124    fn assertions() {
1125        assert_send::<HwpxStyleStore>();
1126        assert_sync::<HwpxStyleStore>();
1127    }
1128};
1129
1130// ── Color parsing helper ─────────────────────────────────────────
1131
1132/// Parses a HWPX hex color string (`"#RRGGBB"`) into a [`Color`].
1133///
1134/// Returns `Color::BLACK` for `"none"`, empty strings, or invalid formats.
1135/// This is intentionally lenient: real-world HWPX files sometimes contain
1136/// non-standard color values, and rejecting them would make the decoder
1137/// unusable for slightly malformed documents.
1138pub(crate) fn parse_hex_color(s: &str) -> Color {
1139    let s = s.trim();
1140    if s.is_empty() || s.eq_ignore_ascii_case("none") {
1141        return Color::BLACK;
1142    }
1143    let hex = s.strip_prefix('#').unwrap_or(s);
1144    if hex.len() != 6 {
1145        return Color::BLACK;
1146    }
1147    let Ok(rgb) = u32::from_str_radix(hex, 16) else {
1148        return Color::BLACK;
1149    };
1150    let r = ((rgb >> 16) & 0xFF) as u8;
1151    let g = ((rgb >> 8) & 0xFF) as u8;
1152    let b = (rgb & 0xFF) as u8;
1153    Color::from_rgb(r, g, b)
1154}
1155
1156/// Parses a HWPX alignment string into an [`Alignment`].
1157///
1158/// Defaults to `Alignment::Left` for unknown values.
1159pub(crate) fn parse_alignment(s: &str) -> Alignment {
1160    if s.eq_ignore_ascii_case("LEFT") {
1161        Alignment::Left
1162    } else if s.eq_ignore_ascii_case("BOTH") || s.eq_ignore_ascii_case("JUSTIFY") {
1163        Alignment::Justify
1164    } else if s.eq_ignore_ascii_case("CENTER") {
1165        Alignment::Center
1166    } else if s.eq_ignore_ascii_case("RIGHT") {
1167        Alignment::Right
1168    } else if s.eq_ignore_ascii_case("DISTRIBUTE") {
1169        Alignment::Distribute
1170    } else if s.eq_ignore_ascii_case("DISTRIBUTE_FLUSH") {
1171        Alignment::DistributeFlush
1172    } else {
1173        Alignment::Left
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180    use hwpforge_blueprint::builtins::builtin_default;
1181    use hwpforge_foundation::{CharShapeIndex, FontIndex, ParaShapeIndex};
1182
1183    // ── HwpxStyleStore basic operations ──────────────────────────
1184
1185    #[test]
1186    fn empty_store_returns_errors() {
1187        let store = HwpxStyleStore::new();
1188        assert!(store.font(FontIndex::new(0)).is_err());
1189        assert!(store.char_shape(CharShapeIndex::new(0)).is_err());
1190        assert!(store.para_shape(ParaShapeIndex::new(0)).is_err());
1191    }
1192
1193    #[test]
1194    fn push_and_get_font() {
1195        let mut store = HwpxStyleStore::new();
1196        let idx = store.push_font(HwpxFont {
1197            id: 0,
1198            face_name: "함초롬돋움".into(),
1199            lang: "HANGUL".into(),
1200        });
1201        assert_eq!(idx.get(), 0);
1202        let font = store.font(idx).unwrap();
1203        assert_eq!(font.face_name, "함초롬돋움");
1204        assert_eq!(font.lang, "HANGUL");
1205    }
1206
1207    #[test]
1208    fn push_and_get_char_shape() {
1209        let mut store = HwpxStyleStore::new();
1210        let shape = HwpxCharShape {
1211            height: HwpUnit::new(1000).unwrap(),
1212            text_color: Color::from_rgb(255, 0, 0),
1213            bold: true,
1214            ..Default::default()
1215        };
1216        let idx = store.push_char_shape(shape);
1217        let cs = store.char_shape(idx).unwrap();
1218        assert_eq!(cs.height.as_i32(), 1000);
1219        assert_eq!(cs.text_color.red(), 255);
1220        assert!(cs.bold);
1221        assert!(!cs.italic);
1222    }
1223
1224    #[test]
1225    fn push_and_get_para_shape() {
1226        let mut store = HwpxStyleStore::new();
1227        let shape =
1228            HwpxParaShape { alignment: Alignment::Center, line_spacing: 200, ..Default::default() };
1229        let idx = store.push_para_shape(shape);
1230        let ps = store.para_shape(idx).unwrap();
1231        assert_eq!(ps.alignment, Alignment::Center);
1232        assert_eq!(ps.line_spacing, 200);
1233    }
1234
1235    #[test]
1236    fn index_out_of_bounds_error() {
1237        let store = HwpxStyleStore::new();
1238        let err = store.char_shape(CharShapeIndex::new(42)).unwrap_err();
1239        match err {
1240            HwpxError::IndexOutOfBounds { kind, index, max } => {
1241                assert_eq!(kind, "char_shape");
1242                assert_eq!(index, 42);
1243                assert_eq!(max, 0);
1244            }
1245            _ => panic!("expected IndexOutOfBounds"),
1246        }
1247    }
1248
1249    #[test]
1250    fn multiple_items_sequential_indices() {
1251        let mut store = HwpxStyleStore::new();
1252        for i in 0..5 {
1253            let idx = store.push_font(HwpxFont {
1254                id: i,
1255                face_name: format!("Font{i}"),
1256                lang: "LATIN".into(),
1257            });
1258            assert_eq!(idx.get(), i as usize);
1259        }
1260        assert_eq!(store.font_count(), 5);
1261        assert_eq!(store.font(FontIndex::new(3)).unwrap().face_name, "Font3");
1262    }
1263
1264    #[test]
1265    fn count_methods() {
1266        let mut store = HwpxStyleStore::new();
1267        assert_eq!(store.font_count(), 0);
1268        assert_eq!(store.char_shape_count(), 0);
1269        assert_eq!(store.para_shape_count(), 0);
1270
1271        store.push_font(HwpxFont { id: 0, face_name: "A".into(), lang: "LATIN".into() });
1272        store.push_char_shape(HwpxCharShape::default());
1273        store.push_char_shape(HwpxCharShape::default());
1274        store.push_para_shape(HwpxParaShape::default());
1275
1276        assert_eq!(store.font_count(), 1);
1277        assert_eq!(store.char_shape_count(), 2);
1278        assert_eq!(store.para_shape_count(), 1);
1279    }
1280
1281    // ── Iterator methods ───────────────────────────────────────────
1282
1283    #[test]
1284    fn iter_fonts_yields_all() {
1285        let mut store = HwpxStyleStore::new();
1286        for i in 0..3 {
1287            store.push_font(HwpxFont {
1288                id: i,
1289                face_name: format!("Font{i}"),
1290                lang: "LATIN".into(),
1291            });
1292        }
1293        let names: Vec<&str> = store.iter_fonts().map(|f| f.face_name.as_str()).collect();
1294        assert_eq!(names, vec!["Font0", "Font1", "Font2"]);
1295    }
1296
1297    #[test]
1298    fn iter_char_shapes_yields_all() {
1299        let mut store = HwpxStyleStore::new();
1300        store.push_char_shape(HwpxCharShape { bold: true, ..Default::default() });
1301        store.push_char_shape(HwpxCharShape { italic: true, ..Default::default() });
1302        let styles: Vec<(bool, bool)> =
1303            store.iter_char_shapes().map(|c| (c.bold, c.italic)).collect();
1304        assert_eq!(styles, vec![(true, false), (false, true)]);
1305    }
1306
1307    #[test]
1308    fn iter_para_shapes_yields_all() {
1309        let mut store = HwpxStyleStore::new();
1310        store.push_para_shape(HwpxParaShape { line_spacing: 130, ..Default::default() });
1311        store.push_para_shape(HwpxParaShape { line_spacing: 200, ..Default::default() });
1312        let spacings: Vec<i32> = store.iter_para_shapes().map(|p| p.line_spacing).collect();
1313        assert_eq!(spacings, vec![130, 200]);
1314    }
1315
1316    #[test]
1317    fn iter_empty_store() {
1318        let store = HwpxStyleStore::new();
1319        assert_eq!(store.iter_fonts().count(), 0);
1320        assert_eq!(store.iter_char_shapes().count(), 0);
1321        assert_eq!(store.iter_para_shapes().count(), 0);
1322    }
1323
1324    // ── HwpxFontRef default ──────────────────────────────────────
1325
1326    #[test]
1327    fn font_ref_default_all_zero() {
1328        let r = HwpxFontRef::default();
1329        assert_eq!(r.hangul.get(), 0);
1330        assert_eq!(r.latin.get(), 0);
1331        assert_eq!(r.hanja.get(), 0);
1332        assert_eq!(r.japanese.get(), 0);
1333        assert_eq!(r.other.get(), 0);
1334        assert_eq!(r.symbol.get(), 0);
1335        assert_eq!(r.user.get(), 0);
1336    }
1337
1338    // ── HwpxCharShape default ────────────────────────────────────
1339
1340    #[test]
1341    fn char_shape_default_values() {
1342        let cs = HwpxCharShape::default();
1343        assert_eq!(cs.height, HwpUnit::new(1000).unwrap()); // 10pt default
1344        assert_eq!(cs.text_color, Color::BLACK);
1345        assert_eq!(cs.shade_color, None);
1346        assert!(!cs.bold);
1347        assert!(!cs.italic);
1348        assert_eq!(cs.underline_type, UnderlineType::None);
1349        assert_eq!(cs.underline_color, None);
1350        assert_eq!(cs.strikeout_shape, StrikeoutShape::None);
1351        assert_eq!(cs.strikeout_color, None);
1352    }
1353
1354    // ── HwpxParaShape default ────────────────────────────────────
1355
1356    #[test]
1357    fn para_shape_default_values() {
1358        let ps = HwpxParaShape::default();
1359        assert_eq!(ps.alignment, Alignment::Left);
1360        assert_eq!(ps.margin_left, HwpUnit::ZERO);
1361        assert_eq!(ps.indent, HwpUnit::ZERO);
1362        assert_eq!(ps.line_spacing, 160);
1363        assert_eq!(ps.line_spacing_type, LineSpacingType::Percentage);
1364    }
1365
1366    // ── parse_hex_color ──────────────────────────────────────────
1367
1368    #[test]
1369    fn parse_hex_color_valid() {
1370        let c = parse_hex_color("#FF0000");
1371        assert_eq!(c.red(), 255);
1372        assert_eq!(c.green(), 0);
1373        assert_eq!(c.blue(), 0);
1374    }
1375
1376    #[test]
1377    fn parse_hex_color_lowercase() {
1378        let c = parse_hex_color("#00ff00");
1379        assert_eq!(c.green(), 255);
1380    }
1381
1382    #[test]
1383    fn parse_hex_color_no_hash() {
1384        let c = parse_hex_color("0000FF");
1385        assert_eq!(c.blue(), 255);
1386    }
1387
1388    #[test]
1389    fn parse_hex_color_none_returns_black() {
1390        assert_eq!(parse_hex_color("none"), Color::BLACK);
1391        assert_eq!(parse_hex_color("NONE"), Color::BLACK);
1392    }
1393
1394    #[test]
1395    fn parse_hex_color_empty_returns_black() {
1396        assert_eq!(parse_hex_color(""), Color::BLACK);
1397    }
1398
1399    #[test]
1400    fn parse_hex_color_invalid_returns_black() {
1401        assert_eq!(parse_hex_color("#GGHHII"), Color::BLACK);
1402        assert_eq!(parse_hex_color("#FFF"), Color::BLACK); // too short
1403        assert_eq!(parse_hex_color("garbage"), Color::BLACK);
1404    }
1405
1406    #[test]
1407    fn parse_hex_color_white() {
1408        let c = parse_hex_color("#FFFFFF");
1409        assert_eq!(c, Color::WHITE);
1410    }
1411
1412    // ── parse_alignment ──────────────────────────────────────────
1413
1414    #[test]
1415    fn parse_alignment_standard() {
1416        assert_eq!(parse_alignment("LEFT"), Alignment::Left);
1417        assert_eq!(parse_alignment("CENTER"), Alignment::Center);
1418        assert_eq!(parse_alignment("RIGHT"), Alignment::Right);
1419        assert_eq!(parse_alignment("JUSTIFY"), Alignment::Justify);
1420    }
1421
1422    #[test]
1423    fn parse_alignment_both_maps_to_justify() {
1424        // HWPX: "BOTH" means 양쪽 맞춤 (Justify), not Left
1425        assert_eq!(parse_alignment("BOTH"), Alignment::Justify);
1426    }
1427
1428    #[test]
1429    fn parse_alignment_case_insensitive() {
1430        assert_eq!(parse_alignment("center"), Alignment::Center);
1431        assert_eq!(parse_alignment("Right"), Alignment::Right);
1432    }
1433
1434    #[test]
1435    fn parse_alignment_distribute() {
1436        assert_eq!(parse_alignment("DISTRIBUTE"), Alignment::Distribute);
1437        assert_eq!(parse_alignment("distribute"), Alignment::Distribute);
1438        assert_eq!(parse_alignment("DISTRIBUTE_FLUSH"), Alignment::DistributeFlush);
1439        assert_eq!(parse_alignment("distribute_flush"), Alignment::DistributeFlush);
1440    }
1441
1442    #[test]
1443    fn parse_alignment_unknown_defaults_left() {
1444        assert_eq!(parse_alignment("DISTRIBUTED"), Alignment::Left);
1445        assert_eq!(parse_alignment(""), Alignment::Left);
1446    }
1447
1448    // ── HwpxStyle operations ────────────────────────────────────
1449
1450    #[test]
1451    fn push_and_get_style() {
1452        let mut store = HwpxStyleStore::new();
1453        let style = HwpxStyle {
1454            id: 0,
1455            style_type: "PARA".into(),
1456            name: "바탕글".into(),
1457            eng_name: "Normal".into(),
1458            para_pr_id_ref: 0,
1459            char_pr_id_ref: 0,
1460            next_style_id_ref: 0,
1461            lang_id: 1042,
1462        };
1463        store.push_style(style);
1464        assert_eq!(store.style_count(), 1);
1465        let s = store.style(0).unwrap();
1466        assert_eq!(s.name, "바탕글");
1467        assert_eq!(s.eng_name, "Normal");
1468        assert_eq!(s.style_type, "PARA");
1469    }
1470
1471    #[test]
1472    fn style_index_out_of_bounds() {
1473        let store = HwpxStyleStore::new();
1474        let err = store.style(0).unwrap_err();
1475        match err {
1476            HwpxError::IndexOutOfBounds { kind, index, max } => {
1477                assert_eq!(kind, "style");
1478                assert_eq!(index, 0);
1479                assert_eq!(max, 0);
1480            }
1481            _ => panic!("expected IndexOutOfBounds"),
1482        }
1483    }
1484
1485    #[test]
1486    fn iter_styles_yields_all() {
1487        let mut store = HwpxStyleStore::new();
1488        store.push_style(HwpxStyle {
1489            id: 0,
1490            style_type: "PARA".into(),
1491            name: "바탕글".into(),
1492            eng_name: "Normal".into(),
1493            para_pr_id_ref: 0,
1494            char_pr_id_ref: 0,
1495            next_style_id_ref: 0,
1496            lang_id: 1042,
1497        });
1498        store.push_style(HwpxStyle {
1499            id: 1,
1500            style_type: "CHAR".into(),
1501            name: "본문".into(),
1502            eng_name: "Body".into(),
1503            para_pr_id_ref: 1,
1504            char_pr_id_ref: 1,
1505            next_style_id_ref: 1,
1506            lang_id: 1042,
1507        });
1508        let names: Vec<&str> = store.iter_styles().map(|s| s.name.as_str()).collect();
1509        assert_eq!(names, vec!["바탕글", "본문"]);
1510    }
1511
1512    // ── from_registry bridge tests ──────────────────────────────
1513
1514    #[test]
1515    fn from_registry_empty_produces_empty_store() {
1516        let registry: StyleRegistry = serde_json::from_str(
1517            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1518        )
1519        .unwrap();
1520        let store = HwpxStyleStore::from_registry(&registry);
1521
1522        // Empty registry injects 한글-compatible defaults:
1523        // 1 font × 7 language groups, 7 default charShapes, 20 default paraShapes,
1524        // 22 required styles (Modern default set)
1525        assert_eq!(store.font_count(), 7);
1526        assert_eq!(store.char_shape_count(), 7); // 7 default charPr groups
1527        assert_eq!(store.para_shape_count(), 20); // 20 default paraPr groups
1528        assert_eq!(store.style_count(), 22);
1529    }
1530
1531    #[test]
1532    fn from_registry_preserves_counts() {
1533        let template = builtin_default().unwrap();
1534        let registry = StyleRegistry::from_template(&template).unwrap();
1535        let store = HwpxStyleStore::from_registry(&registry);
1536
1537        // Fonts are mirrored across 7 language groups (HANGUL, LATIN, HANJA, JAPANESE, OTHER, SYMBOL, USER)
1538        assert_eq!(store.font_count(), registry.font_count() * 7);
1539        // 7 default charShapes + user charShapes; 20 default paraShapes + user paraShapes
1540        assert_eq!(store.char_shape_count(), 7 + registry.char_shape_count());
1541        assert_eq!(store.para_shape_count(), 20 + registry.para_shape_count());
1542        // +22 for injected Modern default styles (the default HancomStyleSet)
1543        assert_eq!(store.style_count(), registry.style_count() + 22);
1544    }
1545
1546    #[test]
1547    fn from_registry_font_face_names_match() {
1548        let template = builtin_default().unwrap();
1549        let registry = StyleRegistry::from_template(&template).unwrap();
1550        let store = HwpxStyleStore::from_registry(&registry);
1551
1552        let font_count = registry.font_count();
1553        let langs = ["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
1554        // Fonts are stored as: lang0[font0, font1, ...], lang1[font0, font1, ...], ...
1555        for (lang_idx, &lang) in langs.iter().enumerate() {
1556            for (font_idx, font_id) in registry.fonts.iter().enumerate() {
1557                let store_idx = lang_idx * font_count + font_idx;
1558                let hwpx_font = store.font(FontIndex::new(store_idx)).unwrap();
1559                assert_eq!(hwpx_font.face_name, font_id.as_str());
1560                assert_eq!(hwpx_font.lang, lang);
1561            }
1562        }
1563    }
1564
1565    #[test]
1566    fn from_registry_char_shape_properties() {
1567        let template = builtin_default().unwrap();
1568        let registry = StyleRegistry::from_template(&template).unwrap();
1569        let store = HwpxStyleStore::from_registry(&registry);
1570
1571        // User charShapes start at index 7 (after 7 default charPr groups)
1572        for (i, bp_cs) in registry.char_shapes.iter().enumerate() {
1573            let hwpx_cs = store.char_shape(CharShapeIndex::new(7 + i)).unwrap();
1574            assert_eq!(hwpx_cs.height, bp_cs.size);
1575            assert_eq!(hwpx_cs.text_color, bp_cs.color);
1576            assert_eq!(hwpx_cs.shade_color, bp_cs.shade_color);
1577            assert_eq!(hwpx_cs.bold, bp_cs.bold);
1578            assert_eq!(hwpx_cs.italic, bp_cs.italic);
1579            assert_eq!(hwpx_cs.underline_type, bp_cs.underline_type);
1580            assert_eq!(hwpx_cs.underline_color, bp_cs.underline_color);
1581            assert_eq!(hwpx_cs.strikeout_shape, bp_cs.strikeout_shape);
1582            assert_eq!(hwpx_cs.strikeout_color, bp_cs.strikeout_color);
1583            assert_eq!(hwpx_cs.vertical_position, bp_cs.vertical_position);
1584            assert_eq!(hwpx_cs.outline_type, bp_cs.outline);
1585            assert_eq!(hwpx_cs.shadow_type, bp_cs.shadow);
1586            assert_eq!(hwpx_cs.emboss_type, bp_cs.emboss);
1587            assert_eq!(hwpx_cs.engrave_type, bp_cs.engrave);
1588        }
1589    }
1590
1591    #[test]
1592    fn from_registry_para_shape_properties() {
1593        let template = builtin_default().unwrap();
1594        let registry = StyleRegistry::from_template(&template).unwrap();
1595        let store = HwpxStyleStore::from_registry(&registry);
1596
1597        // User paraShapes start at index 20 (after 20 default paraPr groups)
1598        for (i, bp_ps) in registry.para_shapes.iter().enumerate() {
1599            let hwpx_ps = store.para_shape(ParaShapeIndex::new(20 + i)).unwrap();
1600            assert_eq!(hwpx_ps.alignment, bp_ps.alignment);
1601            assert_eq!(hwpx_ps.margin_left, bp_ps.indent_left);
1602            assert_eq!(hwpx_ps.margin_right, bp_ps.indent_right);
1603            assert_eq!(hwpx_ps.indent, bp_ps.indent_first_line);
1604            assert_eq!(hwpx_ps.spacing_before, bp_ps.space_before);
1605            assert_eq!(hwpx_ps.spacing_after, bp_ps.space_after);
1606            assert_eq!(hwpx_ps.line_spacing, bp_ps.line_spacing_value.round() as i32);
1607        }
1608    }
1609
1610    #[test]
1611    fn from_registry_style_entries_reference_valid_indices() {
1612        let template = builtin_default().unwrap();
1613        let registry = StyleRegistry::from_template(&template).unwrap();
1614        let store = HwpxStyleStore::from_registry(&registry);
1615
1616        for i in 0..store.style_count() {
1617            let style = store.style(i).unwrap();
1618            // Style type is either "PARA" or "CHAR" (default styles include both)
1619            assert!(
1620                style.style_type == "PARA" || style.style_type == "CHAR",
1621                "unexpected style_type '{}' for style '{}'",
1622                style.style_type,
1623                style.name
1624            );
1625            assert!(
1626                (style.char_pr_id_ref as usize) < store.char_shape_count(),
1627                "char_pr_id_ref {} out of bounds for style '{}'",
1628                style.char_pr_id_ref,
1629                style.name
1630            );
1631            assert!(
1632                (style.para_pr_id_ref as usize) < store.para_shape_count(),
1633                "para_pr_id_ref {} out of bounds for style '{}'",
1634                style.para_pr_id_ref,
1635                style.name
1636            );
1637        }
1638    }
1639
1640    // ── HancomStyleSet count tests ──────────────────────────────
1641
1642    #[test]
1643    fn default_style_set_classic_count() {
1644        assert_eq!(HancomStyleSet::Classic.count(), 18);
1645    }
1646
1647    #[test]
1648    fn default_style_set_modern_count() {
1649        assert_eq!(HancomStyleSet::Modern.count(), 22);
1650    }
1651
1652    #[test]
1653    fn default_style_set_latest_count() {
1654        assert_eq!(HancomStyleSet::Latest.count(), 23);
1655    }
1656
1657    #[test]
1658    fn default_style_set_modern_is_default() {
1659        assert_eq!(HancomStyleSet::default(), HancomStyleSet::Modern);
1660    }
1661
1662    // ── with_default_fonts ───────────────────────────────────────
1663
1664    #[test]
1665    fn with_default_fonts_creates_seven_fonts() {
1666        let store = HwpxStyleStore::with_default_fonts("함초롬돋움");
1667        assert_eq!(store.font_count(), 7);
1668    }
1669
1670    #[test]
1671    fn with_default_fonts_all_names_match() {
1672        let font_name = "나눔고딕";
1673        let store = HwpxStyleStore::with_default_fonts(font_name);
1674        for font in store.iter_fonts() {
1675            assert_eq!(font.face_name, font_name);
1676        }
1677    }
1678
1679    #[test]
1680    fn with_default_fonts_lang_groups_correct() {
1681        let store = HwpxStyleStore::with_default_fonts("함초롬바탕");
1682        let langs: Vec<&str> = store.iter_fonts().map(|f| f.lang.as_str()).collect();
1683        assert_eq!(langs, vec!["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"]);
1684    }
1685
1686    #[test]
1687    fn from_registry_with_classic_style_set() {
1688        let registry: StyleRegistry = serde_json::from_str(
1689            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1690        )
1691        .unwrap();
1692        let store = HwpxStyleStore::from_registry_with(&registry, HancomStyleSet::Classic);
1693        assert_eq!(store.style_set(), HancomStyleSet::Classic);
1694        // Classic injects exactly 18 default styles
1695        assert_eq!(store.style_count(), 18);
1696        // 쪽 번호 at Classic position (id=9)
1697        assert_eq!(store.style(9).unwrap().name, "쪽 번호");
1698    }
1699
1700    #[test]
1701    fn modern_styles_match_golden_fixture() {
1702        // Verified from golden fixture tests/fixtures/textbox.hwpx (한글 2022+)
1703        let styles = HancomStyleSet::Modern.default_styles();
1704        // 개요 8-10 inserted at 9-11
1705        assert_eq!(styles[9].name, "개요 8");
1706        assert_eq!(styles[10].name, "개요 9");
1707        assert_eq!(styles[11].name, "개요 10");
1708        // 쪽 번호 shifted to 12
1709        assert_eq!(styles[12].name, "쪽 번호");
1710        assert_eq!(styles[12].style_type, "CHAR");
1711        // 캡션 at 21
1712        assert_eq!(styles[21].name, "캡션");
1713        assert_eq!(styles[21].style_type, "PARA");
1714    }
1715
1716    // ── Border Fill tests ─────────────────────────────────────────
1717
1718    #[test]
1719    fn default_border_fills_count() {
1720        use hwpforge_blueprint::{builtins::builtin_default, registry::StyleRegistry};
1721        let template = builtin_default().unwrap();
1722        let registry = StyleRegistry::from_template(&template).unwrap();
1723        let store = HwpxStyleStore::from_registry(&registry);
1724        assert_eq!(store.border_fill_count(), 3, "from_registry produces exactly 3 default fills");
1725    }
1726
1727    #[test]
1728    fn default_border_fill_page() {
1729        // id=1: page border — empty borders, no fill
1730        let bf = HwpxBorderFill::default_page_border();
1731        assert_eq!(bf.id, 1);
1732        assert!(!bf.three_d);
1733        assert!(!bf.shadow);
1734        assert_eq!(bf.center_line, "NONE");
1735        assert_eq!(bf.left.line_type, "NONE");
1736        assert_eq!(bf.right.line_type, "NONE");
1737        assert_eq!(bf.top.line_type, "NONE");
1738        assert_eq!(bf.bottom.line_type, "NONE");
1739        assert_eq!(bf.diagonal.line_type, "SOLID");
1740        assert!(bf.fill.is_none());
1741    }
1742
1743    #[test]
1744    fn default_border_fill_char() {
1745        // id=2: char background — must have WinBrush fill
1746        let bf = HwpxBorderFill::default_char_background();
1747        assert_eq!(bf.id, 2);
1748        assert!(bf.fill.is_some(), "char background must have a fill brush");
1749        match bf.fill.as_ref().unwrap() {
1750            HwpxFill::WinBrush { face_color, hatch_color, alpha } => {
1751                assert_eq!(face_color, "none");
1752                assert_eq!(hatch_color, "#FF000000");
1753                assert_eq!(alpha, "0");
1754            }
1755        }
1756    }
1757
1758    #[test]
1759    fn default_border_fill_table() {
1760        // id=3: table border — SOLID on all 4 sides, 0.12 mm
1761        let bf = HwpxBorderFill::default_table_border();
1762        assert_eq!(bf.id, 3);
1763        assert_eq!(bf.left.line_type, "SOLID");
1764        assert_eq!(bf.left.width, "0.12 mm");
1765        assert_eq!(bf.right.line_type, "SOLID");
1766        assert_eq!(bf.top.line_type, "SOLID");
1767        assert_eq!(bf.bottom.line_type, "SOLID");
1768        assert_eq!(bf.diagonal.line_type, "SOLID");
1769        assert_eq!(bf.diagonal.width, "0.1 mm");
1770        assert!(bf.fill.is_none());
1771    }
1772
1773    #[test]
1774    fn push_user_border_fill() {
1775        let mut store = HwpxStyleStore::new();
1776        let bf = HwpxBorderFill {
1777            id: 4,
1778            three_d: false,
1779            shadow: false,
1780            center_line: "NONE".into(),
1781            left: HwpxBorderLine {
1782                line_type: "DASH".into(),
1783                width: "0.2 mm".into(),
1784                color: "#FF0000".into(),
1785            },
1786            right: HwpxBorderLine::default(),
1787            top: HwpxBorderLine::default(),
1788            bottom: HwpxBorderLine::default(),
1789            diagonal: HwpxBorderLine::default(),
1790            slash_type: "NONE".into(),
1791            back_slash_type: "NONE".into(),
1792            fill: None,
1793        };
1794        let returned_id = store.push_border_fill(bf);
1795        assert_eq!(returned_id, 4);
1796        assert_eq!(store.border_fill_count(), 1);
1797        let fetched = store.border_fill(4).unwrap();
1798        assert_eq!(fetched.left.line_type, "DASH");
1799        assert_eq!(fetched.left.width, "0.2 mm");
1800    }
1801
1802    #[test]
1803    fn border_fill_not_found_returns_error() {
1804        let store = HwpxStyleStore::new();
1805        assert!(store.border_fill(1).is_err());
1806    }
1807
1808    #[test]
1809    fn from_registry_border_fills_have_correct_ids() {
1810        use hwpforge_blueprint::{builtins::builtin_default, registry::StyleRegistry};
1811        let template = builtin_default().unwrap();
1812        let registry = StyleRegistry::from_template(&template).unwrap();
1813        let store = HwpxStyleStore::from_registry(&registry);
1814        // IDs are 1-based
1815        assert_eq!(store.border_fill(1).unwrap().id, 1);
1816        assert_eq!(store.border_fill(2).unwrap().id, 2);
1817        assert_eq!(store.border_fill(3).unwrap().id, 3);
1818    }
1819
1820    // ── 7.3 per-style shape injection tests ──────────────────────
1821
1822    #[test]
1823    fn from_registry_injects_7_default_char_shapes() {
1824        let registry: StyleRegistry = serde_json::from_str(
1825            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1826        )
1827        .unwrap();
1828        let store = HwpxStyleStore::from_registry(&registry);
1829        assert_eq!(store.char_shape_count(), 7, "must have exactly 7 default charPr groups");
1830    }
1831
1832    #[test]
1833    fn from_registry_injects_20_default_para_shapes() {
1834        let registry: StyleRegistry = serde_json::from_str(
1835            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1836        )
1837        .unwrap();
1838        let store = HwpxStyleStore::from_registry(&registry);
1839        assert_eq!(store.para_shape_count(), 20, "must have exactly 20 default paraPr groups");
1840    }
1841
1842    #[test]
1843    fn default_char_shape_0_is_batang_10pt_black() {
1844        // charPr 0 = 함초롬바탕 10pt #000000 (바탕글/본문/개요1-7/캡션)
1845        let registry: StyleRegistry = serde_json::from_str(
1846            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1847        )
1848        .unwrap();
1849        let store = HwpxStyleStore::from_registry(&registry);
1850        let cs = store.char_shape(CharShapeIndex::new(0)).unwrap();
1851        assert_eq!(cs.height.as_i32(), 1000); // 10pt
1852        assert_eq!(cs.text_color, Color::BLACK);
1853        assert!(!cs.bold);
1854        assert!(!cs.italic);
1855    }
1856
1857    #[test]
1858    fn default_char_shape_5_is_toc_heading() {
1859        // charPr 5 = 함초롬돋움 16pt #2E74B5 (차례 제목)
1860        let registry: StyleRegistry = serde_json::from_str(
1861            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1862        )
1863        .unwrap();
1864        let store = HwpxStyleStore::from_registry(&registry);
1865        let cs = store.char_shape(CharShapeIndex::new(5)).unwrap();
1866        assert_eq!(cs.height.as_i32(), 1600); // 16pt
1867        assert_eq!(cs.text_color, Color::from_rgb(0x2E, 0x74, 0xB5));
1868    }
1869
1870    #[test]
1871    fn from_registry_user_shapes_offset() {
1872        // User charShapes must start at index 7, user paraShapes at index 20
1873        let template = builtin_default().unwrap();
1874        let registry = StyleRegistry::from_template(&template).unwrap();
1875        let store = HwpxStyleStore::from_registry(&registry);
1876        // First user charShape is at index 7
1877        assert!(store.char_shape(CharShapeIndex::new(7)).is_ok());
1878        // First user paraShape is at index 20
1879        assert!(store.para_shape(ParaShapeIndex::new(20)).is_ok());
1880    }
1881
1882    #[test]
1883    fn from_registry_default_style_refs_match_groups() {
1884        // Default styles must reference the correct charPr/paraPr group indices
1885        let registry: StyleRegistry = serde_json::from_str(
1886            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1887        )
1888        .unwrap();
1889        let store = HwpxStyleStore::from_registry(&registry);
1890        let defaults = HancomStyleSet::Modern.default_styles();
1891        for (idx, entry) in defaults.iter().enumerate() {
1892            let style = store.style(idx).unwrap();
1893            assert_eq!(
1894                style.char_pr_id_ref, entry.char_pr_group as u32,
1895                "charPr ref mismatch for style '{}'",
1896                entry.name
1897            );
1898            assert_eq!(
1899                style.para_pr_id_ref, entry.para_pr_group as u32,
1900                "paraPr ref mismatch for style '{}'",
1901                entry.name
1902            );
1903        }
1904    }
1905
1906    #[test]
1907    fn from_registry_user_style_refs_are_offset_adjusted() {
1908        // User styles' charPr/paraPr refs must be offset by 7/20
1909        let template = builtin_default().unwrap();
1910        let registry = StyleRegistry::from_template(&template).unwrap();
1911        let store = HwpxStyleStore::from_registry(&registry);
1912        let defaults_len = HancomStyleSet::Modern.count();
1913        for (i, (_, entry)) in registry.style_entries.iter().enumerate() {
1914            let style = store.style(defaults_len + i).unwrap();
1915            assert_eq!(
1916                style.char_pr_id_ref,
1917                (entry.char_shape_id.get() + 7) as u32,
1918                "user charPr ref not offset-adjusted for style index {i}"
1919            );
1920            assert_eq!(
1921                style.para_pr_id_ref,
1922                (entry.para_shape_id.get() + 20) as u32,
1923                "user paraPr ref not offset-adjusted for style index {i}"
1924            );
1925        }
1926    }
1927
1928    #[test]
1929    fn default_para_shape_0_is_batanggeul() {
1930        // paraPr 0 = JUSTIFY, left=0, 160% line spacing (바탕글)
1931        let registry: StyleRegistry = serde_json::from_str(
1932            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1933        )
1934        .unwrap();
1935        let store = HwpxStyleStore::from_registry(&registry);
1936        let ps = store.para_shape(ParaShapeIndex::new(0)).unwrap();
1937        assert_eq!(ps.alignment, Alignment::Justify);
1938        assert_eq!(ps.margin_left.as_i32(), 0);
1939        assert_eq!(ps.line_spacing, 160);
1940    }
1941
1942    #[test]
1943    fn default_para_shape_2_is_outline1() {
1944        // paraPr 2 = JUSTIFY, left=1000 (개요 1 with OUTLINE heading)
1945        let registry: StyleRegistry = serde_json::from_str(
1946            r#"{"fonts":[],"char_shapes":[],"para_shapes":[],"style_entries":{}}"#,
1947        )
1948        .unwrap();
1949        let store = HwpxStyleStore::from_registry(&registry);
1950        let ps = store.para_shape(ParaShapeIndex::new(2)).unwrap();
1951        assert_eq!(ps.alignment, Alignment::Justify);
1952        assert_eq!(ps.margin_left.as_i32(), 1000);
1953        assert_eq!(ps.line_spacing, 160);
1954    }
1955}