hwpforge_blueprint/
style.rs

1//! Style types: character shapes, paragraph shapes, and their partial variants.
2//!
3//! The **two-type pattern** is central to Blueprint's design:
4//!
5//! - [`PartialCharShape`] / [`PartialParaShape`] — all fields `Option`,
6//!   used for YAML deserialization and inheritance merging.
7//! - [`CharShape`] / [`ParaShape`] — all fields required, produced after
8//!   inheritance resolution when every field has a concrete value.
9//!
10//! This mirrors CSS inheritance: a child template can override only the
11//! fields it cares about, inheriting the rest from the parent.
12
13use hwpforge_foundation::{
14    Alignment, BorderFillIndex, BreakType, Color, EmbossType, EmphasisType, EngraveType,
15    HeadingType, HwpUnit, LineSpacingType, OutlineType, ShadowType, StrikeoutShape, UnderlineType,
16    VerticalPosition,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::error::{BlueprintError, BlueprintResult};
22use crate::serde_helpers::{
23    de_color, de_color_opt, de_dim, de_dim_opt, de_pct_opt, ser_color, ser_color_opt, ser_dim,
24    ser_dim_opt, ser_pct_opt,
25};
26
27// ---------------------------------------------------------------------------
28// Helper types
29// ---------------------------------------------------------------------------
30
31/// Vertical spacing (before and after a paragraph).
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
33pub struct Spacing {
34    /// Space before the paragraph.
35    #[serde(
36        default,
37        serialize_with = "ser_dim_opt",
38        deserialize_with = "de_dim_opt",
39        skip_serializing_if = "Option::is_none"
40    )]
41    pub before: Option<HwpUnit>,
42    /// Space after the paragraph.
43    #[serde(
44        default,
45        serialize_with = "ser_dim_opt",
46        deserialize_with = "de_dim_opt",
47        skip_serializing_if = "Option::is_none"
48    )]
49    pub after: Option<HwpUnit>,
50}
51
52/// Paragraph indentation.
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
54pub struct Indent {
55    /// Left indentation.
56    #[serde(
57        default,
58        serialize_with = "ser_dim_opt",
59        deserialize_with = "de_dim_opt",
60        skip_serializing_if = "Option::is_none"
61    )]
62    pub left: Option<HwpUnit>,
63    /// Right indentation.
64    #[serde(
65        default,
66        serialize_with = "ser_dim_opt",
67        deserialize_with = "de_dim_opt",
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub right: Option<HwpUnit>,
71    /// First-line indentation (can be negative for hanging indent).
72    #[serde(
73        default,
74        serialize_with = "ser_dim_opt",
75        deserialize_with = "de_dim_opt",
76        skip_serializing_if = "Option::is_none"
77    )]
78    pub first_line: Option<HwpUnit>,
79}
80
81/// Line spacing configuration.
82#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
83pub struct LineSpacing {
84    /// Spacing type (percentage, fixed, between-lines).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub spacing_type: Option<LineSpacingType>,
87    /// The value: percentage (e.g. 160.0 for 160%) or fixed HwpUnit.
88    #[serde(
89        default,
90        serialize_with = "ser_pct_opt",
91        deserialize_with = "de_pct_opt",
92        skip_serializing_if = "Option::is_none"
93    )]
94    pub value: Option<f64>,
95}
96
97// ---------------------------------------------------------------------------
98// Partial types (for YAML and inheritance merging)
99// ---------------------------------------------------------------------------
100
101/// Character shape with all optional fields (for YAML parsing and inheritance).
102///
103/// After inheritance resolution, this is converted to [`CharShape`] where
104/// all fields are guaranteed to be present.
105#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
106pub struct PartialCharShape {
107    /// Font name (e.g. "한컴바탕", "Arial").
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub font: Option<String>,
110    /// Font size.
111    #[serde(
112        default,
113        serialize_with = "ser_dim_opt",
114        deserialize_with = "de_dim_opt",
115        skip_serializing_if = "Option::is_none"
116    )]
117    pub size: Option<HwpUnit>,
118    /// Bold weight.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub bold: Option<bool>,
121    /// Italic style.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub italic: Option<bool>,
124    /// Text color in `#RRGGBB`.
125    #[serde(
126        default,
127        serialize_with = "ser_color_opt",
128        deserialize_with = "de_color_opt",
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub color: Option<Color>,
132
133    /// Underline type (None/Bottom/Center/Top).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub underline_type: Option<UnderlineType>,
136    /// Underline color (inherits text color if None).
137    #[serde(
138        default,
139        serialize_with = "ser_color_opt",
140        deserialize_with = "de_color_opt",
141        skip_serializing_if = "Option::is_none"
142    )]
143    pub underline_color: Option<Color>,
144    /// Strikeout line shape (None/Continuous/Dash/Dot/etc.).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub strikeout_shape: Option<StrikeoutShape>,
147    /// Strikeout color (inherits text color if None).
148    #[serde(
149        default,
150        serialize_with = "ser_color_opt",
151        deserialize_with = "de_color_opt",
152        skip_serializing_if = "Option::is_none"
153    )]
154    pub strikeout_color: Option<Color>,
155    /// Text outline (1pt border around glyphs).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub outline: Option<OutlineType>,
158    /// Drop shadow.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub shadow: Option<ShadowType>,
161    /// Emboss effect (raised).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub emboss: Option<EmbossType>,
164    /// Engrave effect (sunken).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub engrave: Option<EngraveType>,
167    /// Vertical position (Normal/Superscript/Subscript).
168    /// Replaces bool superscript/subscript (backward compat: both supported).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub vertical_position: Option<VerticalPosition>,
171    /// Background shade color (character-level highlight).
172    #[serde(
173        default,
174        serialize_with = "ser_color_opt",
175        deserialize_with = "de_color_opt",
176        skip_serializing_if = "Option::is_none"
177    )]
178    pub shade_color: Option<Color>,
179    /// Emphasis mark type (symMark: NONE, DOT_ABOVE, etc.).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub emphasis: Option<EmphasisType>,
182    /// Character width ratio (percent, uniform across all 7 lang groups).
183    /// Default: 100. Range: 50-200.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub ratio: Option<i32>,
186    /// Inter-character spacing (percent, uniform).
187    /// Default: 0. Negative = tighter.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub spacing: Option<i32>,
190    /// Relative font size (percent, uniform).
191    /// Default: 100.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub rel_sz: Option<i32>,
194    /// Vertical position offset (percent, uniform).
195    /// Default: 0.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub offset: Option<i32>,
198    /// Enable kerning (pair adjustment). Default: false.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub use_kerning: Option<bool>,
201    /// Use font-defined inter-character spacing. Default: false.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub use_font_space: Option<bool>,
204    /// Border/fill reference for character-level border.
205    ///
206    /// References a `borderFill` entry by raw ID. `None` = use the default
207    /// `borderFillIDRef=2` (한글 default char background, no visible border).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub char_border_fill_id: Option<u32>,
210}
211
212impl PartialCharShape {
213    /// Merges `other` into `self` (child overrides parent).
214    /// Fields in `other` with `Some` value override `self`.
215    pub fn merge(&mut self, other: &PartialCharShape) {
216        if other.font.is_some() {
217            self.font.clone_from(&other.font);
218        }
219        if other.size.is_some() {
220            self.size = other.size;
221        }
222        if other.bold.is_some() {
223            self.bold = other.bold;
224        }
225        if other.italic.is_some() {
226            self.italic = other.italic;
227        }
228        if other.color.is_some() {
229            self.color = other.color;
230        }
231        if other.underline_type.is_some() {
232            self.underline_type = other.underline_type;
233        }
234        if other.underline_color.is_some() {
235            self.underline_color = other.underline_color;
236        }
237        if other.strikeout_shape.is_some() {
238            self.strikeout_shape = other.strikeout_shape;
239        }
240        if other.strikeout_color.is_some() {
241            self.strikeout_color = other.strikeout_color;
242        }
243        if other.outline.is_some() {
244            self.outline = other.outline;
245        }
246        if other.shadow.is_some() {
247            self.shadow = other.shadow;
248        }
249        if other.emboss.is_some() {
250            self.emboss = other.emboss;
251        }
252        if other.engrave.is_some() {
253            self.engrave = other.engrave;
254        }
255        if other.vertical_position.is_some() {
256            self.vertical_position = other.vertical_position;
257        }
258        if other.shade_color.is_some() {
259            self.shade_color = other.shade_color;
260        }
261        if other.emphasis.is_some() {
262            self.emphasis = other.emphasis;
263        }
264        if other.ratio.is_some() {
265            self.ratio = other.ratio;
266        }
267        if other.spacing.is_some() {
268            self.spacing = other.spacing;
269        }
270        if other.rel_sz.is_some() {
271            self.rel_sz = other.rel_sz;
272        }
273        if other.offset.is_some() {
274            self.offset = other.offset;
275        }
276        if other.use_kerning.is_some() {
277            self.use_kerning = other.use_kerning;
278        }
279        if other.use_font_space.is_some() {
280            self.use_font_space = other.use_font_space;
281        }
282        if other.char_border_fill_id.is_some() {
283            self.char_border_fill_id = other.char_border_fill_id;
284        }
285    }
286
287    /// Attempts to resolve this partial into a fully-specified [`CharShape`].
288    ///
289    /// Returns an error naming the first missing required field.
290    pub fn resolve(&self, style_name: &str) -> BlueprintResult<CharShape> {
291        Ok(CharShape {
292            font: self.font.clone().ok_or_else(|| BlueprintError::StyleResolution {
293                style_name: style_name.to_string(),
294                field: "font".to_string(),
295            })?,
296            size: self.size.ok_or_else(|| BlueprintError::StyleResolution {
297                style_name: style_name.to_string(),
298                field: "size".to_string(),
299            })?,
300            bold: self.bold.unwrap_or(false),
301            italic: self.italic.unwrap_or(false),
302            color: self.color.unwrap_or(Color::BLACK),
303            underline_type: self.underline_type.unwrap_or(UnderlineType::None),
304            underline_color: self.underline_color,
305            strikeout_shape: self.strikeout_shape.unwrap_or(StrikeoutShape::None),
306            strikeout_color: self.strikeout_color,
307            outline: self.outline.unwrap_or(OutlineType::None),
308            shadow: self.shadow.unwrap_or(ShadowType::None),
309            emboss: self.emboss.unwrap_or(EmbossType::None),
310            engrave: self.engrave.unwrap_or(EngraveType::None),
311            vertical_position: self.vertical_position.unwrap_or(VerticalPosition::Normal),
312            shade_color: self.shade_color,
313            emphasis: self.emphasis.unwrap_or(EmphasisType::None),
314            ratio: self.ratio.unwrap_or(100),
315            spacing: self.spacing.unwrap_or(0),
316            rel_sz: self.rel_sz.unwrap_or(100),
317            offset: self.offset.unwrap_or(0),
318            use_kerning: self.use_kerning.unwrap_or(false),
319            use_font_space: self.use_font_space.unwrap_or(false),
320            char_border_fill_id: self.char_border_fill_id,
321        })
322    }
323}
324
325/// Paragraph shape with all optional fields (for YAML parsing and inheritance).
326#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
327pub struct PartialParaShape {
328    /// Text alignment.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub alignment: Option<Alignment>,
331    /// Line spacing.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub line_spacing: Option<LineSpacing>,
334    /// Vertical spacing (before/after paragraph).
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub spacing: Option<Spacing>,
337    /// Indentation.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub indent: Option<Indent>,
340
341    // Advanced paragraph controls (NEW in Phase 5.3)
342    /// Page/column break before paragraph.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub break_type: Option<BreakType>,
345    /// Keep paragraph with next (prevent page break between).
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub keep_with_next: Option<bool>,
348    /// Keep lines together (prevent page break within paragraph).
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub keep_lines_together: Option<bool>,
351    /// Widow/orphan control (minimum 2 lines).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub widow_orphan: Option<bool>,
354    /// Border/fill reference (for paragraph borders and backgrounds).
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub border_fill_id: Option<BorderFillIndex>,
357    /// Heading type for outline/numbering styles.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub heading_type: Option<HeadingType>,
360}
361
362impl Spacing {
363    /// Merges `other` into `self` (child fields override parent fields).
364    pub fn merge(&mut self, other: &Spacing) {
365        if other.before.is_some() {
366            self.before = other.before;
367        }
368        if other.after.is_some() {
369            self.after = other.after;
370        }
371    }
372}
373
374impl Indent {
375    /// Merges `other` into `self` (child fields override parent fields).
376    pub fn merge(&mut self, other: &Indent) {
377        if other.left.is_some() {
378            self.left = other.left;
379        }
380        if other.right.is_some() {
381            self.right = other.right;
382        }
383        if other.first_line.is_some() {
384            self.first_line = other.first_line;
385        }
386    }
387}
388
389impl LineSpacing {
390    /// Merges `other` into `self` (child fields override parent fields).
391    pub fn merge(&mut self, other: &LineSpacing) {
392        if other.spacing_type.is_some() {
393            self.spacing_type = other.spacing_type;
394        }
395        if other.value.is_some() {
396            self.value = other.value;
397        }
398    }
399}
400
401impl PartialParaShape {
402    /// Merges `other` into `self` (child overrides parent, field-level deep merge).
403    ///
404    /// Nested structs (line_spacing, spacing, indent) are merged at the field
405    /// level, not replaced wholesale. This means a child can override
406    /// `spacing.after` while inheriting `spacing.before` from the parent.
407    pub fn merge(&mut self, other: &PartialParaShape) {
408        if other.alignment.is_some() {
409            self.alignment = other.alignment;
410        }
411        // Deep merge: merge nested struct fields individually
412        match (&mut self.line_spacing, &other.line_spacing) {
413            (Some(base), Some(child)) => base.merge(child),
414            (None, Some(child)) => self.line_spacing = Some(*child),
415            _ => {}
416        }
417        match (&mut self.spacing, &other.spacing) {
418            (Some(base), Some(child)) => base.merge(child),
419            (None, Some(child)) => self.spacing = Some(*child),
420            _ => {}
421        }
422        match (&mut self.indent, &other.indent) {
423            (Some(base), Some(child)) => base.merge(child),
424            (None, Some(child)) => self.indent = Some(*child),
425            _ => {}
426        }
427        if other.break_type.is_some() {
428            self.break_type = other.break_type;
429        }
430        if other.keep_with_next.is_some() {
431            self.keep_with_next = other.keep_with_next;
432        }
433        if other.keep_lines_together.is_some() {
434            self.keep_lines_together = other.keep_lines_together;
435        }
436        if other.widow_orphan.is_some() {
437            self.widow_orphan = other.widow_orphan;
438        }
439        if other.border_fill_id.is_some() {
440            self.border_fill_id = other.border_fill_id;
441        }
442        if other.heading_type.is_some() {
443            self.heading_type = other.heading_type;
444        }
445    }
446
447    /// Resolves into a fully-specified [`ParaShape`] with defaults.
448    pub fn resolve(&self) -> ParaShape {
449        ParaShape {
450            alignment: self.alignment.unwrap_or(Alignment::Left),
451            line_spacing_type: self
452                .line_spacing
453                .and_then(|ls| ls.spacing_type)
454                .unwrap_or(LineSpacingType::Percentage),
455            line_spacing_value: self.line_spacing.and_then(|ls| ls.value).unwrap_or(160.0),
456            space_before: self.spacing.and_then(|s| s.before).unwrap_or(HwpUnit::ZERO),
457            space_after: self.spacing.and_then(|s| s.after).unwrap_or(HwpUnit::ZERO),
458            indent_left: self.indent.and_then(|i| i.left).unwrap_or(HwpUnit::ZERO),
459            indent_right: self.indent.and_then(|i| i.right).unwrap_or(HwpUnit::ZERO),
460            indent_first_line: self.indent.and_then(|i| i.first_line).unwrap_or(HwpUnit::ZERO),
461            break_type: self.break_type.unwrap_or(BreakType::None),
462            keep_with_next: self.keep_with_next.unwrap_or(false),
463            keep_lines_together: self.keep_lines_together.unwrap_or(false),
464            widow_orphan: self.widow_orphan.unwrap_or(true), // Enabled by default in HWPX
465            border_fill_id: self.border_fill_id,
466            heading_type: self.heading_type.unwrap_or(HeadingType::None),
467        }
468    }
469}
470
471/// A composite style entry (char + para shape) with optional fields for YAML.
472#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
473pub struct PartialStyle {
474    /// Character formatting.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub char_shape: Option<PartialCharShape>,
477    /// Paragraph formatting.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub para_shape: Option<PartialParaShape>,
480}
481
482impl PartialStyle {
483    /// Merges `other` into `self`.
484    pub fn merge(&mut self, other: &PartialStyle) {
485        match (&mut self.char_shape, &other.char_shape) {
486            (Some(base), Some(child)) => base.merge(child),
487            (None, Some(child)) => self.char_shape = Some(child.clone()),
488            _ => {}
489        }
490        match (&mut self.para_shape, &other.para_shape) {
491            (Some(base), Some(child)) => base.merge(child),
492            (None, Some(child)) => self.para_shape = Some(child.clone()),
493            _ => {}
494        }
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Resolved (full) types
500// ---------------------------------------------------------------------------
501
502/// A fully-resolved character shape (all fields present).
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
504pub struct CharShape {
505    /// Font name.
506    pub font: String,
507    /// Font size.
508    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
509    pub size: HwpUnit,
510    /// Bold.
511    pub bold: bool,
512    /// Italic.
513    pub italic: bool,
514    /// Text color.
515    #[serde(serialize_with = "ser_color", deserialize_with = "de_color")]
516    pub color: Color,
517    /// Underline type.
518    pub underline_type: UnderlineType,
519    /// Underline color (None = inherit text color).
520    #[serde(
521        default,
522        serialize_with = "ser_color_opt",
523        deserialize_with = "de_color_opt",
524        skip_serializing_if = "Option::is_none"
525    )]
526    pub underline_color: Option<Color>,
527    /// Strikeout line shape.
528    pub strikeout_shape: StrikeoutShape,
529    /// Strikeout color (None = inherit text color).
530    #[serde(
531        default,
532        serialize_with = "ser_color_opt",
533        deserialize_with = "de_color_opt",
534        skip_serializing_if = "Option::is_none"
535    )]
536    pub strikeout_color: Option<Color>,
537    /// Text outline.
538    pub outline: OutlineType,
539    /// Drop shadow.
540    pub shadow: ShadowType,
541    /// Emboss effect.
542    pub emboss: EmbossType,
543    /// Engrave effect.
544    pub engrave: EngraveType,
545    /// Vertical position (replaces superscript/subscript bools).
546    pub vertical_position: VerticalPosition,
547    /// Background shade color (None = transparent).
548    #[serde(
549        default,
550        serialize_with = "ser_color_opt",
551        deserialize_with = "de_color_opt",
552        skip_serializing_if = "Option::is_none"
553    )]
554    pub shade_color: Option<Color>,
555    /// Emphasis mark type (symMark). Default: None.
556    pub emphasis: EmphasisType,
557    /// Character width ratio (percent, uniform). Default: 100.
558    pub ratio: i32,
559    /// Inter-character spacing (percent, uniform). Default: 0.
560    pub spacing: i32,
561    /// Relative font size (percent, uniform). Default: 100.
562    pub rel_sz: i32,
563    /// Vertical position offset (percent, uniform). Default: 0.
564    pub offset: i32,
565    /// Enable kerning. Default: false.
566    pub use_kerning: bool,
567    /// Use font-defined inter-character spacing. Default: false.
568    pub use_font_space: bool,
569    /// Border/fill reference for character-level border (None = default).
570    ///
571    /// When `None`, the HWPX encoder uses `borderFillIDRef=2` (the 한글 default
572    /// char background, which has no visible border). Set to `Some(id)` to
573    /// reference a custom borderFill entry for character-level borders.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub char_border_fill_id: Option<u32>,
576}
577
578/// A fully-resolved paragraph shape (all fields present).
579#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
580pub struct ParaShape {
581    /// Text alignment.
582    pub alignment: Alignment,
583    /// Line spacing type.
584    pub line_spacing_type: LineSpacingType,
585    /// Line spacing value (percentage or fixed).
586    pub line_spacing_value: f64,
587    /// Space before paragraph.
588    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
589    pub space_before: HwpUnit,
590    /// Space after paragraph.
591    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
592    pub space_after: HwpUnit,
593    /// Left indentation.
594    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
595    pub indent_left: HwpUnit,
596    /// Right indentation.
597    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
598    pub indent_right: HwpUnit,
599    /// First-line indentation.
600    #[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
601    pub indent_first_line: HwpUnit,
602
603    // Advanced paragraph controls (NEW in Phase 5.3)
604    /// Page/column break type.
605    pub break_type: BreakType,
606    /// Keep paragraph with next.
607    pub keep_with_next: bool,
608    /// Keep lines together.
609    pub keep_lines_together: bool,
610    /// Widow/orphan control (default: true).
611    pub widow_orphan: bool,
612    /// Border/fill reference (None = no border/fill).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub border_fill_id: Option<BorderFillIndex>,
615    /// Heading type (None, Outline, Number, Bullet). Default: None.
616    #[serde(default)]
617    pub heading_type: HeadingType,
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use pretty_assertions::assert_eq;
624
625    #[test]
626    fn partial_char_shape_default_is_all_none() {
627        let p = PartialCharShape::default();
628        assert!(p.font.is_none());
629        assert!(p.size.is_none());
630        assert!(p.bold.is_none());
631        assert!(p.italic.is_none());
632        assert!(p.color.is_none());
633        assert!(p.underline_type.is_none());
634        assert!(p.strikeout_shape.is_none());
635        assert!(p.vertical_position.is_none());
636    }
637
638    #[test]
639    fn partial_char_shape_merge_overrides() {
640        let mut base = PartialCharShape {
641            font: Some("Arial".into()),
642            size: Some(HwpUnit::from_pt(10.0).unwrap()),
643            bold: Some(false),
644            ..Default::default()
645        };
646        let child = PartialCharShape {
647            size: Some(HwpUnit::from_pt(16.0).unwrap()),
648            bold: Some(true),
649            ..Default::default()
650        };
651        base.merge(&child);
652        assert_eq!(base.font, Some("Arial".into()));
653        assert_eq!(base.size, Some(HwpUnit::from_pt(16.0).unwrap()));
654        assert_eq!(base.bold, Some(true));
655    }
656
657    #[test]
658    fn partial_char_shape_merge_none_does_not_override() {
659        let mut base = PartialCharShape { font: Some("Batang".into()), ..Default::default() };
660        let child = PartialCharShape::default();
661        base.merge(&child);
662        assert_eq!(base.font, Some("Batang".into()));
663    }
664
665    #[test]
666    fn partial_char_shape_resolve_success() {
667        let partial = PartialCharShape {
668            font: Some("한컴바탕".into()),
669            size: Some(HwpUnit::from_pt(10.0).unwrap()),
670            ..Default::default()
671        };
672        let resolved = partial.resolve("body").unwrap();
673        assert_eq!(resolved.font, "한컴바탕");
674        assert_eq!(resolved.size, HwpUnit::from_pt(10.0).unwrap());
675        assert!(!resolved.bold);
676        assert_eq!(resolved.color, Color::BLACK);
677    }
678
679    #[test]
680    fn partial_char_shape_resolve_missing_font() {
681        let partial =
682            PartialCharShape { size: Some(HwpUnit::from_pt(10.0).unwrap()), ..Default::default() };
683        let err = partial.resolve("heading1").unwrap_err();
684        assert!(err.to_string().contains("font"));
685        assert!(err.to_string().contains("heading1"));
686    }
687
688    #[test]
689    fn partial_char_shape_resolve_missing_size() {
690        let partial = PartialCharShape { font: Some("Arial".into()), ..Default::default() };
691        let err = partial.resolve("body").unwrap_err();
692        assert!(err.to_string().contains("size"));
693    }
694
695    #[test]
696    fn partial_para_shape_default_is_all_none() {
697        let p = PartialParaShape::default();
698        assert!(p.alignment.is_none());
699        assert!(p.line_spacing.is_none());
700        assert!(p.spacing.is_none());
701        assert!(p.indent.is_none());
702    }
703
704    #[test]
705    fn partial_para_shape_merge_overrides() {
706        let mut base = PartialParaShape {
707            alignment: Some(Alignment::Left),
708            line_spacing: Some(LineSpacing {
709                spacing_type: Some(LineSpacingType::Percentage),
710                value: Some(160.0),
711            }),
712            ..Default::default()
713        };
714        let child = PartialParaShape {
715            line_spacing: Some(LineSpacing { spacing_type: None, value: Some(170.0) }),
716            ..Default::default()
717        };
718        base.merge(&child);
719        assert_eq!(base.alignment, Some(Alignment::Left));
720        let ls = base.line_spacing.unwrap();
721        assert_eq!(ls.value, Some(170.0)); // Overridden by child
722        assert_eq!(ls.spacing_type, Some(LineSpacingType::Percentage)); // Preserved from base (deep merge)
723    }
724
725    #[test]
726    fn partial_para_shape_deep_merge_spacing() {
727        let mut base = PartialParaShape {
728            spacing: Some(Spacing { before: Some(HwpUnit::from_pt(6.0).unwrap()), after: None }),
729            ..Default::default()
730        };
731        let child = PartialParaShape {
732            spacing: Some(Spacing { before: None, after: Some(HwpUnit::from_pt(12.0).unwrap()) }),
733            ..Default::default()
734        };
735        base.merge(&child);
736        let sp = base.spacing.unwrap();
737        assert_eq!(sp.before, Some(HwpUnit::from_pt(6.0).unwrap())); // Preserved from base
738        assert_eq!(sp.after, Some(HwpUnit::from_pt(12.0).unwrap())); // Added by child
739    }
740
741    #[test]
742    fn partial_para_shape_deep_merge_indent() {
743        let mut base = PartialParaShape {
744            indent: Some(Indent {
745                left: Some(HwpUnit::from_pt(10.0).unwrap()),
746                right: None,
747                first_line: Some(HwpUnit::from_pt(5.0).unwrap()),
748            }),
749            ..Default::default()
750        };
751        let child = PartialParaShape {
752            indent: Some(Indent {
753                left: None,
754                right: Some(HwpUnit::from_pt(8.0).unwrap()),
755                first_line: None,
756            }),
757            ..Default::default()
758        };
759        base.merge(&child);
760        let indent = base.indent.unwrap();
761        assert_eq!(indent.left, Some(HwpUnit::from_pt(10.0).unwrap())); // Preserved
762        assert_eq!(indent.right, Some(HwpUnit::from_pt(8.0).unwrap())); // Added
763        assert_eq!(indent.first_line, Some(HwpUnit::from_pt(5.0).unwrap())); // Preserved
764    }
765
766    #[test]
767    fn partial_para_shape_resolve_defaults() {
768        let partial = PartialParaShape::default();
769        let resolved = partial.resolve();
770        assert_eq!(resolved.alignment, Alignment::Left);
771        assert_eq!(resolved.line_spacing_type, LineSpacingType::Percentage);
772        assert_eq!(resolved.line_spacing_value, 160.0);
773        assert_eq!(resolved.space_before, HwpUnit::ZERO);
774        assert_eq!(resolved.indent_left, HwpUnit::ZERO);
775    }
776
777    #[test]
778    fn partial_style_merge_both_present() {
779        let mut base = PartialStyle {
780            char_shape: Some(PartialCharShape {
781                font: Some("Arial".into()),
782                size: Some(HwpUnit::from_pt(10.0).unwrap()),
783                ..Default::default()
784            }),
785            para_shape: Some(PartialParaShape {
786                alignment: Some(Alignment::Left),
787                ..Default::default()
788            }),
789        };
790        let child = PartialStyle {
791            char_shape: Some(PartialCharShape {
792                size: Some(HwpUnit::from_pt(16.0).unwrap()),
793                bold: Some(true),
794                ..Default::default()
795            }),
796            para_shape: None,
797        };
798        base.merge(&child);
799        let cs = base.char_shape.unwrap();
800        assert_eq!(cs.font, Some("Arial".into()));
801        assert_eq!(cs.size, Some(HwpUnit::from_pt(16.0).unwrap()));
802        assert_eq!(cs.bold, Some(true));
803    }
804
805    #[test]
806    fn partial_style_merge_none_base() {
807        let mut base = PartialStyle::default();
808        let child = PartialStyle {
809            char_shape: Some(PartialCharShape { font: Some("Dotum".into()), ..Default::default() }),
810            para_shape: None,
811        };
812        base.merge(&child);
813        assert_eq!(base.char_shape.unwrap().font, Some("Dotum".into()));
814    }
815
816    #[test]
817    fn char_shape_serde_roundtrip() {
818        let original = CharShape {
819            font: "한컴바탕".into(),
820            size: HwpUnit::from_pt(16.0).unwrap(),
821            bold: true,
822            italic: false,
823            color: Color::from_rgb(0x00, 0x33, 0x66),
824            underline_type: UnderlineType::None,
825            underline_color: None,
826            strikeout_shape: StrikeoutShape::None,
827            strikeout_color: None,
828            outline: OutlineType::None,
829            shadow: ShadowType::None,
830            emboss: EmbossType::None,
831            engrave: EngraveType::None,
832            vertical_position: VerticalPosition::Normal,
833            shade_color: None,
834            emphasis: EmphasisType::None,
835            ratio: 100,
836            spacing: 0,
837            rel_sz: 100,
838            offset: 0,
839            use_kerning: false,
840            use_font_space: false,
841            char_border_fill_id: None,
842        };
843        let yaml = serde_yaml::to_string(&original).unwrap();
844        let back: CharShape = serde_yaml::from_str(&yaml).unwrap();
845        assert_eq!(original, back);
846    }
847
848    #[test]
849    fn char_shape_yaml_contains_human_readable() {
850        let cs = CharShape {
851            font: "Arial".into(),
852            size: HwpUnit::from_pt(12.0).unwrap(),
853            bold: false,
854            italic: true,
855            color: Color::RED,
856            underline_type: UnderlineType::None,
857            underline_color: None,
858            strikeout_shape: StrikeoutShape::None,
859            strikeout_color: None,
860            outline: OutlineType::None,
861            shadow: ShadowType::None,
862            emboss: EmbossType::None,
863            engrave: EngraveType::None,
864            vertical_position: VerticalPosition::Normal,
865            shade_color: None,
866            emphasis: EmphasisType::None,
867            ratio: 100,
868            spacing: 0,
869            rel_sz: 100,
870            offset: 0,
871            use_kerning: false,
872            use_font_space: false,
873            char_border_fill_id: None,
874        };
875        let yaml = serde_yaml::to_string(&cs).unwrap();
876        assert!(yaml.contains("12pt"), "Expected '12pt' in: {yaml}");
877        assert!(yaml.contains("#FF0000"), "Expected '#FF0000' in: {yaml}");
878    }
879
880    #[test]
881    fn para_shape_serde_roundtrip() {
882        let original = ParaShape {
883            alignment: Alignment::Justify,
884            line_spacing_type: LineSpacingType::Percentage,
885            line_spacing_value: 170.0,
886            space_before: HwpUnit::from_pt(6.0).unwrap(),
887            space_after: HwpUnit::from_pt(6.0).unwrap(),
888            indent_left: HwpUnit::ZERO,
889            indent_right: HwpUnit::ZERO,
890            indent_first_line: HwpUnit::ZERO,
891            break_type: BreakType::None,
892            keep_with_next: false,
893            keep_lines_together: false,
894            widow_orphan: true,
895            border_fill_id: None,
896            heading_type: HeadingType::None,
897        };
898        let yaml = serde_yaml::to_string(&original).unwrap();
899        let back: ParaShape = serde_yaml::from_str(&yaml).unwrap();
900        assert_eq!(original, back);
901    }
902
903    #[test]
904    fn partial_char_shape_from_yaml() {
905        let yaml = "font: 한컴바탕\nsize: 16pt\nbold: true\ncolor: '#003366'\n";
906        let partial: PartialCharShape = serde_yaml::from_str(yaml).unwrap();
907        assert_eq!(partial.font, Some("한컴바탕".into()));
908        assert_eq!(partial.size, Some(HwpUnit::from_pt(16.0).unwrap()));
909        assert_eq!(partial.bold, Some(true));
910        assert_eq!(partial.color, Some(Color::from_rgb(0x00, 0x33, 0x66)));
911        assert!(partial.italic.is_none());
912    }
913
914    #[test]
915    fn partial_para_shape_from_yaml() {
916        let yaml = "alignment: Justify\nline_spacing:\n  value: '170%'\nspacing:\n  before: '6pt'\n  after: '6pt'\n";
917        let partial: PartialParaShape = serde_yaml::from_str(yaml).unwrap();
918        assert_eq!(partial.alignment, Some(Alignment::Justify));
919        assert_eq!(partial.line_spacing.unwrap().value, Some(170.0));
920        assert_eq!(partial.spacing.unwrap().before, Some(HwpUnit::from_pt(6.0).unwrap()));
921    }
922
923    #[test]
924    fn partial_style_from_yaml() {
925        let yaml = "char_shape:\n  font: Arial\n  size: '10pt'\npara_shape:\n  alignment: Left\n";
926        let style: PartialStyle = serde_yaml::from_str(yaml).unwrap();
927        assert_eq!(style.char_shape.as_ref().unwrap().font, Some("Arial".into()));
928        assert_eq!(style.para_shape.as_ref().unwrap().alignment, Some(Alignment::Left));
929    }
930
931    #[test]
932    fn spacing_from_yaml() {
933        let yaml = "before: '6pt'\nafter: '12pt'\n";
934        let spacing: Spacing = serde_yaml::from_str(yaml).unwrap();
935        assert_eq!(spacing.before, Some(HwpUnit::from_pt(6.0).unwrap()));
936        assert_eq!(spacing.after, Some(HwpUnit::from_pt(12.0).unwrap()));
937    }
938
939    #[test]
940    fn indent_from_yaml() {
941        let yaml = "left: '20mm'\nfirst_line: '10pt'\n";
942        let indent: Indent = serde_yaml::from_str(yaml).unwrap();
943        assert_eq!(indent.left, Some(HwpUnit::from_mm(20.0).unwrap()));
944        assert_eq!(indent.first_line, Some(HwpUnit::from_pt(10.0).unwrap()));
945        assert!(indent.right.is_none());
946    }
947
948    #[test]
949    fn line_spacing_from_yaml() {
950        let yaml = "spacing_type: Percentage\nvalue: '160%'\n";
951        let ls: LineSpacing = serde_yaml::from_str(yaml).unwrap();
952        assert_eq!(ls.spacing_type, Some(LineSpacingType::Percentage));
953        assert_eq!(ls.value, Some(160.0));
954    }
955}