hwpforge_blueprint/
template.rs

1//! Template definition and YAML parsing.
2//!
3//! A **Template** is a YAML-serializable container for:
4//! - Metadata (name, version, parent template reference)
5//! - Page settings (dimensions, margins)
6//! - Style definitions (character and paragraph styles)
7//! - Markdown-to-style mappings
8//!
9//! Templates support inheritance via the `extends` field, allowing
10//! templates to build upon each other like CSS cascading.
11
12use indexmap::IndexMap;
13
14use hwpforge_core::PageSettings;
15use hwpforge_foundation::HwpUnit;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::error::{BlueprintError, BlueprintResult};
20use crate::serde_helpers::{de_dim_opt, ser_dim_opt};
21use crate::style::PartialStyle;
22
23// ---------------------------------------------------------------------------
24// TemplateMeta
25// ---------------------------------------------------------------------------
26
27/// Template metadata (name, version, parent reference).
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
29pub struct TemplateMeta {
30    /// Template name (required).
31    pub name: String,
32
33    /// Template version (defaults to "1.0.0").
34    #[serde(default = "default_version")]
35    pub version: String,
36
37    /// Optional description.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40
41    /// Parent template name for inheritance.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub extends: Option<String>,
44}
45
46fn default_version() -> String {
47    "1.0.0".to_string()
48}
49
50// ---------------------------------------------------------------------------
51// PageStyle
52// ---------------------------------------------------------------------------
53
54/// Page dimensions and margins with optional fields (for YAML).
55///
56/// After parsing, use [`PageStyle::to_page_settings()`] to convert to
57/// a fully-resolved [`PageSettings`] with defaults.
58#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
59pub struct PageStyle {
60    /// Page width.
61    #[serde(
62        default,
63        serialize_with = "ser_dim_opt",
64        deserialize_with = "de_dim_opt",
65        skip_serializing_if = "Option::is_none"
66    )]
67    pub width: Option<HwpUnit>,
68
69    /// Page height.
70    #[serde(
71        default,
72        serialize_with = "ser_dim_opt",
73        deserialize_with = "de_dim_opt",
74        skip_serializing_if = "Option::is_none"
75    )]
76    pub height: Option<HwpUnit>,
77
78    /// Top margin.
79    #[serde(
80        default,
81        serialize_with = "ser_dim_opt",
82        deserialize_with = "de_dim_opt",
83        skip_serializing_if = "Option::is_none"
84    )]
85    pub margin_top: Option<HwpUnit>,
86
87    /// Bottom margin.
88    #[serde(
89        default,
90        serialize_with = "ser_dim_opt",
91        deserialize_with = "de_dim_opt",
92        skip_serializing_if = "Option::is_none"
93    )]
94    pub margin_bottom: Option<HwpUnit>,
95
96    /// Left margin.
97    #[serde(
98        default,
99        serialize_with = "ser_dim_opt",
100        deserialize_with = "de_dim_opt",
101        skip_serializing_if = "Option::is_none"
102    )]
103    pub margin_left: Option<HwpUnit>,
104
105    /// Right margin.
106    #[serde(
107        default,
108        serialize_with = "ser_dim_opt",
109        deserialize_with = "de_dim_opt",
110        skip_serializing_if = "Option::is_none"
111    )]
112    pub margin_right: Option<HwpUnit>,
113
114    /// Header margin.
115    #[serde(
116        default,
117        serialize_with = "ser_dim_opt",
118        deserialize_with = "de_dim_opt",
119        skip_serializing_if = "Option::is_none"
120    )]
121    pub header_margin: Option<HwpUnit>,
122
123    /// Footer margin.
124    #[serde(
125        default,
126        serialize_with = "ser_dim_opt",
127        deserialize_with = "de_dim_opt",
128        skip_serializing_if = "Option::is_none"
129    )]
130    pub footer_margin: Option<HwpUnit>,
131}
132
133impl PageStyle {
134    /// A4 page settings for templates.
135    pub fn a4() -> Self {
136        let a4 = PageSettings::a4();
137        Self {
138            width: Some(a4.width),
139            height: Some(a4.height),
140            margin_top: Some(a4.margin_top),
141            margin_bottom: Some(a4.margin_bottom),
142            margin_left: Some(a4.margin_left),
143            margin_right: Some(a4.margin_right),
144            header_margin: Some(a4.header_margin),
145            footer_margin: Some(a4.footer_margin),
146        }
147    }
148
149    /// Converts to [`PageSettings`], using A4 defaults for `None` fields.
150    pub fn to_page_settings(&self) -> PageSettings {
151        let a4 = PageSettings::a4();
152        PageSettings {
153            width: self.width.unwrap_or(a4.width),
154            height: self.height.unwrap_or(a4.height),
155            margin_top: self.margin_top.unwrap_or(a4.margin_top),
156            margin_bottom: self.margin_bottom.unwrap_or(a4.margin_bottom),
157            margin_left: self.margin_left.unwrap_or(a4.margin_left),
158            margin_right: self.margin_right.unwrap_or(a4.margin_right),
159            header_margin: self.header_margin.unwrap_or(a4.header_margin),
160            footer_margin: self.footer_margin.unwrap_or(a4.footer_margin),
161            ..a4
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// MarkdownMapping
168// ---------------------------------------------------------------------------
169
170/// Maps markdown elements to style names.
171///
172/// Example YAML:
173/// ```yaml
174/// markdown_mapping:
175///   body: body_style
176///   heading1: h1_style
177///   code: code_style
178/// ```
179#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
180pub struct MarkdownMapping {
181    /// Body text style.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub body: Option<String>,
184
185    /// Heading 1 style.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub heading1: Option<String>,
188
189    /// Heading 2 style.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub heading2: Option<String>,
192
193    /// Heading 3 style.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub heading3: Option<String>,
196
197    /// Heading 4 style.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub heading4: Option<String>,
200
201    /// Heading 5 style.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub heading5: Option<String>,
204
205    /// Heading 6 style.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub heading6: Option<String>,
208
209    /// Code block style.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub code: Option<String>,
212
213    /// Blockquote style.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub blockquote: Option<String>,
216
217    /// List item style.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub list_item: Option<String>,
220}
221
222// ---------------------------------------------------------------------------
223// Template
224// ---------------------------------------------------------------------------
225
226/// A complete YAML template.
227///
228/// Contains metadata, page settings, style definitions, and markdown mappings.
229///
230/// # Example YAML
231///
232/// ```yaml
233/// meta:
234///   name: government_proposal
235///   version: 1.0.0
236///   description: Korean government proposal template
237///
238/// page:
239///   width: 210mm
240///   height: 297mm
241///   margin_top: 20mm
242///   margin_bottom: 20mm
243///   margin_left: 20mm
244///   margin_right: 20mm
245///
246/// styles:
247///   body:
248///     char_shape:
249///       font: 한컴바탕
250///       size: 10pt
251///     para_shape:
252///       alignment: Left
253///
254/// markdown_mapping:
255///   body: body
256///   heading1: h1
257/// ```
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
259pub struct Template {
260    /// Template metadata.
261    pub meta: TemplateMeta,
262
263    /// Page settings (optional).
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub page: Option<PageStyle>,
266
267    /// Style definitions (name → PartialStyle).
268    #[serde(default)]
269    pub styles: IndexMap<String, PartialStyle>,
270
271    /// Markdown element mappings (optional).
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub markdown_mapping: Option<MarkdownMapping>,
274}
275
276impl Template {
277    /// Parses a template from YAML string.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`BlueprintError::YamlParse`] if the YAML is invalid.
282    pub fn from_yaml(yaml: &str) -> BlueprintResult<Self> {
283        serde_yaml::from_str(yaml).map_err(|e| BlueprintError::YamlParse { message: e.to_string() })
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use hwpforge_foundation::{Alignment, Color};
291    use pretty_assertions::assert_eq;
292
293    use crate::style::{PartialCharShape, PartialParaShape};
294
295    // -----------------------------------------------------------------------
296    // TemplateMeta
297    // -----------------------------------------------------------------------
298
299    #[test]
300    fn template_meta_from_yaml_minimal() {
301        let yaml = "name: test_template";
302        let meta: TemplateMeta = serde_yaml::from_str(yaml).unwrap();
303        assert_eq!(meta.name, "test_template");
304        assert_eq!(meta.version, "1.0.0");
305        assert!(meta.description.is_none());
306        assert!(meta.extends.is_none());
307    }
308
309    #[test]
310    fn template_meta_from_yaml_full() {
311        let yaml = r#"
312name: child_template
313version: 2.0.0
314description: Child template for testing
315extends: parent_template
316"#;
317        let meta: TemplateMeta = serde_yaml::from_str(yaml).unwrap();
318        assert_eq!(meta.name, "child_template");
319        assert_eq!(meta.version, "2.0.0");
320        assert_eq!(meta.description, Some("Child template for testing".to_string()));
321        assert_eq!(meta.extends, Some("parent_template".to_string()));
322    }
323
324    #[test]
325    fn template_meta_with_extends() {
326        let yaml = r#"
327name: derived
328extends: base
329"#;
330        let meta: TemplateMeta = serde_yaml::from_str(yaml).unwrap();
331        assert_eq!(meta.extends, Some("base".to_string()));
332    }
333
334    #[test]
335    fn template_meta_without_extends() {
336        let yaml = "name: standalone";
337        let meta: TemplateMeta = serde_yaml::from_str(yaml).unwrap();
338        assert!(meta.extends.is_none());
339    }
340
341    // -----------------------------------------------------------------------
342    // PageStyle
343    // -----------------------------------------------------------------------
344
345    #[test]
346    fn page_style_default_is_all_none() {
347        let ps = PageStyle::default();
348        assert!(ps.width.is_none());
349        assert!(ps.height.is_none());
350        assert!(ps.margin_top.is_none());
351        assert!(ps.margin_bottom.is_none());
352        assert!(ps.margin_left.is_none());
353        assert!(ps.margin_right.is_none());
354        assert!(ps.header_margin.is_none());
355        assert!(ps.footer_margin.is_none());
356    }
357
358    #[test]
359    fn page_style_a4_constructor() {
360        let ps = PageStyle::a4();
361        assert!(ps.width.is_some());
362        assert!(ps.height.is_some());
363        let settings = ps.to_page_settings();
364        assert!((settings.width.to_mm() - 210.0).abs() < 0.1);
365        assert!((settings.height.to_mm() - 297.0).abs() < 0.1);
366    }
367
368    #[test]
369    fn page_style_to_page_settings_uses_a4_defaults() {
370        let ps = PageStyle { width: Some(HwpUnit::from_mm(100.0).unwrap()), ..Default::default() };
371        let settings = ps.to_page_settings();
372        assert_eq!(settings.width, HwpUnit::from_mm(100.0).unwrap());
373        // Other fields should be A4 defaults
374        assert!((settings.height.to_mm() - 297.0).abs() < 0.1);
375        assert!((settings.margin_top.to_mm() - 20.0).abs() < 0.1);
376    }
377
378    #[test]
379    fn page_style_from_yaml() {
380        let yaml = r#"
381width: 210mm
382height: 297mm
383margin_top: 25mm
384margin_bottom: 25mm
385margin_left: 30mm
386margin_right: 30mm
387header_margin: 15mm
388footer_margin: 15mm
389"#;
390        let ps: PageStyle = serde_yaml::from_str(yaml).unwrap();
391        assert_eq!(ps.width, Some(HwpUnit::from_mm(210.0).unwrap()));
392        assert_eq!(ps.height, Some(HwpUnit::from_mm(297.0).unwrap()));
393        assert_eq!(ps.margin_top, Some(HwpUnit::from_mm(25.0).unwrap()));
394        assert_eq!(ps.header_margin, Some(HwpUnit::from_mm(15.0).unwrap()));
395    }
396
397    #[test]
398    fn page_style_partial_yaml() {
399        let yaml = "width: 100mm\nheight: 200mm";
400        let ps: PageStyle = serde_yaml::from_str(yaml).unwrap();
401        assert_eq!(ps.width, Some(HwpUnit::from_mm(100.0).unwrap()));
402        assert_eq!(ps.height, Some(HwpUnit::from_mm(200.0).unwrap()));
403        assert!(ps.margin_top.is_none());
404    }
405
406    // -----------------------------------------------------------------------
407    // MarkdownMapping
408    // -----------------------------------------------------------------------
409
410    #[test]
411    fn markdown_mapping_default_is_all_none() {
412        let mm = MarkdownMapping::default();
413        assert!(mm.body.is_none());
414        assert!(mm.heading1.is_none());
415        assert!(mm.heading2.is_none());
416        assert!(mm.heading3.is_none());
417        assert!(mm.heading4.is_none());
418        assert!(mm.heading5.is_none());
419        assert!(mm.heading6.is_none());
420        assert!(mm.code.is_none());
421        assert!(mm.blockquote.is_none());
422        assert!(mm.list_item.is_none());
423    }
424
425    #[test]
426    fn markdown_mapping_from_yaml() {
427        let yaml = r#"
428body: body_style
429heading1: h1_style
430heading2: h2_style
431code: code_style
432blockquote: quote_style
433list_item: list_style
434"#;
435        let mm: MarkdownMapping = serde_yaml::from_str(yaml).unwrap();
436        assert_eq!(mm.body, Some("body_style".to_string()));
437        assert_eq!(mm.heading1, Some("h1_style".to_string()));
438        assert_eq!(mm.heading2, Some("h2_style".to_string()));
439        assert_eq!(mm.code, Some("code_style".to_string()));
440        assert_eq!(mm.blockquote, Some("quote_style".to_string()));
441        assert_eq!(mm.list_item, Some("list_style".to_string()));
442        assert!(mm.heading3.is_none());
443    }
444
445    #[test]
446    fn markdown_mapping_partial_yaml() {
447        let yaml = "body: body\nheading1: h1";
448        let mm: MarkdownMapping = serde_yaml::from_str(yaml).unwrap();
449        assert_eq!(mm.body, Some("body".to_string()));
450        assert_eq!(mm.heading1, Some("h1".to_string()));
451        assert!(mm.code.is_none());
452    }
453
454    // -----------------------------------------------------------------------
455    // Template
456    // -----------------------------------------------------------------------
457
458    #[test]
459    fn template_from_yaml_minimal() {
460        let yaml = r#"
461meta:
462  name: minimal_template
463"#;
464        let tmpl = Template::from_yaml(yaml).unwrap();
465        assert_eq!(tmpl.meta.name, "minimal_template");
466        assert_eq!(tmpl.meta.version, "1.0.0");
467        assert!(tmpl.page.is_none());
468        assert!(tmpl.styles.is_empty());
469        assert!(tmpl.markdown_mapping.is_none());
470    }
471
472    #[test]
473    fn template_from_yaml_full() {
474        let yaml = r#"
475meta:
476  name: full_template
477  version: 2.0.0
478  description: Full template example
479
480page:
481  width: 210mm
482  height: 297mm
483  margin_top: 20mm
484  margin_bottom: 20mm
485  margin_left: 20mm
486  margin_right: 20mm
487
488styles:
489  body:
490    char_shape:
491      font: 한컴바탕
492      size: 10pt
493      color: '#000000'
494    para_shape:
495      alignment: Left
496  heading1:
497    char_shape:
498      font: 한컴바탕
499      size: 16pt
500      bold: true
501
502markdown_mapping:
503  body: body
504  heading1: heading1
505"#;
506        let tmpl = Template::from_yaml(yaml).unwrap();
507        assert_eq!(tmpl.meta.name, "full_template");
508        assert_eq!(tmpl.meta.version, "2.0.0");
509        assert!(tmpl.page.is_some());
510        assert_eq!(tmpl.styles.len(), 2);
511
512        let body_style = tmpl.styles.get("body").unwrap();
513        assert_eq!(body_style.char_shape.as_ref().unwrap().font, Some("한컴바탕".to_string()));
514        assert_eq!(
515            body_style.char_shape.as_ref().unwrap().size,
516            Some(HwpUnit::from_pt(10.0).unwrap())
517        );
518        assert_eq!(body_style.para_shape.as_ref().unwrap().alignment, Some(Alignment::Left));
519
520        let h1_style = tmpl.styles.get("heading1").unwrap();
521        assert_eq!(h1_style.char_shape.as_ref().unwrap().bold, Some(true));
522
523        let mapping = tmpl.markdown_mapping.as_ref().unwrap();
524        assert_eq!(mapping.body, Some("body".to_string()));
525        assert_eq!(mapping.heading1, Some("heading1".to_string()));
526    }
527
528    #[test]
529    fn template_from_yaml_with_extends() {
530        let yaml = r#"
531meta:
532  name: child
533  extends: parent
534
535styles:
536  custom:
537    char_shape:
538      font: Arial
539      size: 12pt
540"#;
541        let tmpl = Template::from_yaml(yaml).unwrap();
542        assert_eq!(tmpl.meta.name, "child");
543        assert_eq!(tmpl.meta.extends, Some("parent".to_string()));
544        assert_eq!(tmpl.styles.len(), 1);
545    }
546
547    #[test]
548    fn template_from_yaml_invalid_yaml_error() {
549        let yaml = "meta:\n  name: [invalid";
550        let err = Template::from_yaml(yaml).unwrap_err();
551        assert!(matches!(err, BlueprintError::YamlParse { .. }));
552        assert!(err.to_string().contains("YAML parse error"));
553    }
554
555    #[test]
556    fn template_preserves_yaml_style_declaration_order() {
557        let yaml = r#"
558meta:
559  name: ordered
560
561styles:
562  z_style:
563    char_shape:
564      font: A
565      size: 10pt
566  a_style:
567    char_shape:
568      font: B
569      size: 12pt
570  m_style:
571    char_shape:
572      font: C
573      size: 14pt
574"#;
575        let tmpl = Template::from_yaml(yaml).unwrap();
576        let keys: Vec<&String> = tmpl.styles.keys().collect();
577        // IndexMap preserves YAML declaration order (z, a, m — not alphabetical)
578        assert_eq!(keys, vec!["z_style", "a_style", "m_style"]);
579    }
580
581    #[test]
582    fn template_serde_roundtrip() {
583        let mut styles = IndexMap::new();
584        styles.insert(
585            "body".to_string(),
586            PartialStyle {
587                char_shape: Some(PartialCharShape {
588                    font: Some("한컴바탕".to_string()),
589                    size: Some(HwpUnit::from_pt(10.0).unwrap()),
590                    color: Some(Color::BLACK),
591                    ..Default::default()
592                }),
593                para_shape: Some(PartialParaShape {
594                    alignment: Some(Alignment::Justify),
595                    ..Default::default()
596                }),
597            },
598        );
599
600        let original = Template {
601            meta: TemplateMeta {
602                name: "test".to_string(),
603                version: "1.0.0".to_string(),
604                description: None,
605                extends: None,
606            },
607            page: None, // Skip page to avoid floating-point rounding
608            styles,
609            markdown_mapping: Some(MarkdownMapping {
610                body: Some("body".to_string()),
611                ..Default::default()
612            }),
613        };
614
615        let yaml = serde_yaml::to_string(&original).unwrap();
616        let roundtripped = Template::from_yaml(&yaml).unwrap();
617        assert_eq!(original, roundtripped);
618    }
619
620    #[test]
621    fn template_empty_styles_is_valid() {
622        let yaml = r#"
623meta:
624  name: empty_styles
625styles: {}
626"#;
627        let tmpl = Template::from_yaml(yaml).unwrap();
628        assert!(tmpl.styles.is_empty());
629    }
630
631    #[test]
632    fn page_style_serde_skips_none_fields() {
633        let ps = PageStyle {
634            width: Some(HwpUnit::from_mm(210.0).unwrap()),
635            height: Some(HwpUnit::from_mm(297.0).unwrap()),
636            ..Default::default()
637        };
638        let yaml = serde_yaml::to_string(&ps).unwrap();
639        assert!(yaml.contains("width"));
640        assert!(yaml.contains("height"));
641        assert!(!yaml.contains("margin_top"));
642        assert!(!yaml.contains("header_margin"));
643    }
644}