1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
29pub struct TemplateMeta {
30 pub name: String,
32
33 #[serde(default = "default_version")]
35 pub version: String,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub description: Option<String>,
40
41 #[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
59pub struct PageStyle {
60 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
180pub struct MarkdownMapping {
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub body: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub heading1: Option<String>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub heading2: Option<String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub heading3: Option<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub heading4: Option<String>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub heading5: Option<String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub heading6: Option<String>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub code: Option<String>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub blockquote: Option<String>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub list_item: Option<String>,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
259pub struct Template {
260 pub meta: TemplateMeta,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub page: Option<PageStyle>,
266
267 #[serde(default)]
269 pub styles: IndexMap<String, PartialStyle>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub markdown_mapping: Option<MarkdownMapping>,
274}
275
276impl Template {
277 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 #[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 #[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 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 #[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 #[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 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, 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}