hwpforge_blueprint/
builtins.rs

1//! Built-in templates embedded at compile time.
2//!
3//! These templates are included via [`include_str!`] and parsed on first access.
4//! They serve as the defaults for the HwpForge toolchain:
5//!
6//! - **default**: A4 paper, 한컴바탕 font, body/heading/code/quote styles
7//! - **gov_proposal**: Korean government proposal formatting (extends default)
8
9use crate::error::BlueprintResult;
10use crate::template::Template;
11
12/// Raw YAML source for the default template.
13pub const DEFAULT_YAML: &str = include_str!("../templates/default.yaml");
14
15/// Raw YAML source for the government proposal template.
16pub const GOV_PROPOSAL_YAML: &str = include_str!("../templates/gov_proposal.yaml");
17
18/// Parses and returns the built-in default template.
19///
20/// The default template provides:
21/// - A4 page (210mm x 297mm, 20mm margins)
22/// - 7 styles: body, heading1-3, code, blockquote, list_item
23/// - 한컴바탕 font, 10pt body, justified alignment
24///
25/// # Errors
26///
27/// Returns [`crate::error::BlueprintError::YamlParse`] if the embedded YAML is malformed
28/// (should never happen for built-in templates).
29pub fn builtin_default() -> BlueprintResult<Template> {
30    Template::from_yaml(DEFAULT_YAML)
31}
32
33/// Parses and returns the built-in government proposal template.
34///
35/// The government proposal template extends default with:
36/// - Wider margins (30mm left for binding)
37/// - Larger body text (11pt)
38/// - 170% line spacing
39/// - Additional title/subtitle styles
40///
41/// **Note**: This template uses `extends: default`. Use
42/// [`crate::inheritance::resolve_template`] to fully resolve it.
43///
44/// # Errors
45///
46/// Returns [`crate::error::BlueprintError::YamlParse`] if the embedded YAML is malformed.
47pub fn builtin_gov_proposal() -> BlueprintResult<Template> {
48    Template::from_yaml(GOV_PROPOSAL_YAML)
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::inheritance::resolve_template;
55    use crate::registry::StyleRegistry;
56    use hwpforge_foundation::{Alignment, HwpUnit};
57    use pretty_assertions::assert_eq;
58    use std::collections::HashMap;
59
60    // -----------------------------------------------------------------------
61    // YAML validity (golden tests for embedded templates)
62    // -----------------------------------------------------------------------
63
64    #[test]
65    fn default_yaml_is_valid() {
66        let tmpl = builtin_default().unwrap();
67        assert_eq!(tmpl.meta.name, "default");
68        assert_eq!(tmpl.meta.version, "1.0.0");
69        assert!(tmpl.meta.extends.is_none());
70    }
71
72    #[test]
73    fn gov_proposal_yaml_is_valid() {
74        let tmpl = builtin_gov_proposal().unwrap();
75        assert_eq!(tmpl.meta.name, "gov_proposal");
76        assert_eq!(tmpl.meta.extends, Some("default".to_string()));
77    }
78
79    // -----------------------------------------------------------------------
80    // Default template structure
81    // -----------------------------------------------------------------------
82
83    #[test]
84    fn default_has_expected_styles() {
85        let tmpl = builtin_default().unwrap();
86        let expected =
87            ["body", "heading1", "heading2", "heading3", "code", "blockquote", "list_item"];
88        for name in &expected {
89            assert!(tmpl.styles.contains_key(*name), "missing style: {name}");
90        }
91        assert_eq!(tmpl.styles.len(), expected.len());
92    }
93
94    #[test]
95    fn default_has_a4_page() {
96        let tmpl = builtin_default().unwrap();
97        let page = tmpl.page.as_ref().unwrap();
98        let settings = page.to_page_settings();
99        assert!((settings.width.to_mm() - 210.0).abs() < 0.5);
100        assert!((settings.height.to_mm() - 297.0).abs() < 0.5);
101    }
102
103    #[test]
104    fn default_body_is_10pt_justify() {
105        let tmpl = builtin_default().unwrap();
106        let body = tmpl.styles.get("body").unwrap();
107        let cs = body.char_shape.as_ref().unwrap();
108        assert_eq!(cs.font, Some("한컴바탕".to_string()));
109        assert_eq!(cs.size, Some(HwpUnit::from_pt(10.0).unwrap()));
110        let ps = body.para_shape.as_ref().unwrap();
111        assert_eq!(ps.alignment, Some(Alignment::Justify));
112    }
113
114    #[test]
115    fn default_heading1_is_bold_16pt() {
116        let tmpl = builtin_default().unwrap();
117        let h1 = tmpl.styles.get("heading1").unwrap();
118        let cs = h1.char_shape.as_ref().unwrap();
119        assert_eq!(cs.size, Some(HwpUnit::from_pt(16.0).unwrap()));
120        assert_eq!(cs.bold, Some(true));
121    }
122
123    #[test]
124    fn default_has_markdown_mapping() {
125        let tmpl = builtin_default().unwrap();
126        let md = tmpl.markdown_mapping.as_ref().unwrap();
127        assert_eq!(md.body, Some("body".to_string()));
128        assert_eq!(md.heading1, Some("heading1".to_string()));
129        assert_eq!(md.code, Some("code".to_string()));
130    }
131
132    // -----------------------------------------------------------------------
133    // Government proposal template
134    // -----------------------------------------------------------------------
135
136    #[test]
137    fn gov_proposal_has_title_style() {
138        let tmpl = builtin_gov_proposal().unwrap();
139        assert!(tmpl.styles.contains_key("title"));
140        assert!(tmpl.styles.contains_key("subtitle"));
141    }
142
143    #[test]
144    fn gov_proposal_overrides_body_size() {
145        let tmpl = builtin_gov_proposal().unwrap();
146        let body = tmpl.styles.get("body").unwrap();
147        let cs = body.char_shape.as_ref().unwrap();
148        assert_eq!(cs.size, Some(HwpUnit::from_pt(11.0).unwrap()));
149    }
150
151    #[test]
152    fn gov_proposal_wider_left_margin() {
153        let tmpl = builtin_gov_proposal().unwrap();
154        let page = tmpl.page.as_ref().unwrap();
155        assert_eq!(page.margin_left, Some(HwpUnit::from_mm(30.0).unwrap()));
156    }
157
158    // -----------------------------------------------------------------------
159    // Inheritance resolution
160    // -----------------------------------------------------------------------
161
162    #[test]
163    fn gov_proposal_resolves_from_default() {
164        let default = builtin_default().unwrap();
165        let gov = builtin_gov_proposal().unwrap();
166
167        let mut provider = HashMap::new();
168        provider.insert("default".to_string(), default);
169        provider.insert("gov_proposal".to_string(), gov.clone());
170
171        let resolved = resolve_template(&gov, &provider).unwrap();
172
173        // Should have merged styles from default + gov_proposal additions
174        assert!(resolved.meta.extends.is_none());
175        assert!(resolved.styles.contains_key("body")); // From default
176        assert!(resolved.styles.contains_key("heading1")); // From default
177        assert!(resolved.styles.contains_key("title")); // From gov_proposal
178        assert!(resolved.styles.contains_key("subtitle")); // From gov_proposal
179        assert!(resolved.styles.contains_key("code")); // Inherited from default
180
181        // body should have gov_proposal overrides merged with default
182        let body = resolved.styles.get("body").unwrap();
183        let cs = body.char_shape.as_ref().unwrap();
184        // Font from default (not overridden in gov)
185        assert_eq!(cs.font, Some("한컴바탕".to_string()));
186        // Size from gov_proposal (overridden)
187        assert_eq!(cs.size, Some(HwpUnit::from_pt(11.0).unwrap()));
188    }
189
190    // -----------------------------------------------------------------------
191    // StyleRegistry from resolved template
192    // -----------------------------------------------------------------------
193
194    #[test]
195    fn default_template_creates_valid_registry() {
196        let tmpl = builtin_default().unwrap();
197        let registry = StyleRegistry::from_template(&tmpl).unwrap();
198
199        assert_eq!(registry.style_count(), 7);
200        assert!(registry.get_style("body").is_some());
201        assert!(registry.get_style("heading1").is_some());
202    }
203
204    #[test]
205    fn resolved_gov_proposal_creates_valid_registry() {
206        let default = builtin_default().unwrap();
207        let gov = builtin_gov_proposal().unwrap();
208
209        let mut provider = HashMap::new();
210        provider.insert("default".to_string(), default);
211        provider.insert("gov_proposal".to_string(), gov.clone());
212
213        let resolved = resolve_template(&gov, &provider).unwrap();
214        let registry = StyleRegistry::from_template(&resolved).unwrap();
215
216        // 7 from default + 2 from gov (title, subtitle)
217        assert_eq!(registry.style_count(), 9);
218        assert!(registry.get_style("title").is_some());
219
220        // Font deduplication: 한컴바탕 + D2Coding = 2 unique fonts
221        assert_eq!(registry.font_count(), 2);
222    }
223
224    // -----------------------------------------------------------------------
225    // Roundtrip: parse → serialize → parse
226    // -----------------------------------------------------------------------
227
228    #[test]
229    fn default_yaml_roundtrip() {
230        let original = builtin_default().unwrap();
231        let yaml = serde_yaml::to_string(&original).unwrap();
232        let roundtripped = Template::from_yaml(&yaml).unwrap();
233        assert_eq!(original.meta.name, roundtripped.meta.name);
234        assert_eq!(original.styles.len(), roundtripped.styles.len());
235    }
236
237    #[test]
238    fn gov_proposal_yaml_roundtrip() {
239        let original = builtin_gov_proposal().unwrap();
240        let yaml = serde_yaml::to_string(&original).unwrap();
241        let roundtripped = Template::from_yaml(&yaml).unwrap();
242        assert_eq!(original.meta.name, roundtripped.meta.name);
243        assert_eq!(original.styles.len(), roundtripped.styles.len());
244    }
245}