1use std::collections::BTreeMap;
24
25use indexmap::IndexMap;
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use hwpforge_foundation::{CharShapeIndex, FontId, FontIndex, ParaShapeIndex};
30
31use crate::error::{BlueprintError, BlueprintResult};
32use crate::style::{CharShape, ParaShape};
33use crate::template::Template;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
40#[non_exhaustive]
41pub struct StyleEntry {
42 pub char_shape_id: CharShapeIndex,
44 pub para_shape_id: ParaShapeIndex,
46 pub font_id: FontIndex,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
80#[non_exhaustive]
81pub struct StyleRegistry {
82 pub fonts: Vec<FontId>,
84 pub char_shapes: Vec<CharShape>,
86 pub para_shapes: Vec<ParaShape>,
88 pub style_entries: IndexMap<String, StyleEntry>,
90}
91
92impl StyleRegistry {
93 pub fn from_template(template: &Template) -> BlueprintResult<Self> {
120 if template.styles.is_empty() {
121 return Err(BlueprintError::EmptyStyleMap);
122 }
123
124 for name in template.styles.keys() {
126 validate_style_name(name)?;
127 }
128
129 let mut fonts = Vec::new();
130 let mut char_shapes = Vec::new();
131 let mut para_shapes = Vec::new();
132 let mut style_entries = IndexMap::new();
133
134 let mut font_indices: BTreeMap<String, FontIndex> = BTreeMap::new();
136
137 for (style_name, partial_style) in &template.styles {
138 let partial_char = partial_style.char_shape.as_ref().ok_or_else(|| {
140 BlueprintError::StyleResolution {
141 style_name: style_name.clone(),
142 field: "char_shape".to_string(),
143 }
144 })?;
145
146 let char_shape = partial_char.resolve(style_name)?;
147
148 let font_idx = if let Some(&existing_idx) = font_indices.get(&char_shape.font) {
150 existing_idx
151 } else {
152 let font_id = FontId::new(char_shape.font.clone())?;
153 let new_idx = FontIndex::new(fonts.len());
154 fonts.push(font_id);
155 font_indices.insert(char_shape.font.clone(), new_idx);
156 new_idx
157 };
158
159 let char_shape_id = CharShapeIndex::new(char_shapes.len());
160 char_shapes.push(char_shape);
161
162 let partial_para = partial_style.para_shape.as_ref();
164 let para_shape = partial_para.map_or_else(
165 || {
166 crate::style::PartialParaShape::default().resolve()
168 },
169 |p| p.resolve(),
170 );
171
172 let para_shape_id = ParaShapeIndex::new(para_shapes.len());
173 para_shapes.push(para_shape);
174
175 style_entries.insert(
177 style_name.clone(),
178 StyleEntry { char_shape_id, para_shape_id, font_id: font_idx },
179 );
180 }
181
182 if let Some(ref md) = template.markdown_mapping {
184 validate_mapping_references(md, &style_entries)?;
185 }
186
187 Ok(StyleRegistry { fonts, char_shapes, para_shapes, style_entries })
188 }
189
190 pub fn get_style(&self, name: &str) -> Option<&StyleEntry> {
194 self.style_entries.get(name)
195 }
196
197 pub fn char_shape(&self, idx: CharShapeIndex) -> Option<&CharShape> {
201 self.char_shapes.get(idx.get())
202 }
203
204 pub fn para_shape(&self, idx: ParaShapeIndex) -> Option<&ParaShape> {
208 self.para_shapes.get(idx.get())
209 }
210
211 pub fn font(&self, idx: FontIndex) -> Option<&FontId> {
215 self.fonts.get(idx.get())
216 }
217
218 pub fn font_count(&self) -> usize {
220 self.fonts.len()
221 }
222
223 pub fn char_shape_count(&self) -> usize {
225 self.char_shapes.len()
226 }
227
228 pub fn para_shape_count(&self) -> usize {
230 self.para_shapes.len()
231 }
232
233 pub fn style_count(&self) -> usize {
235 self.style_entries.len()
236 }
237}
238
239fn validate_style_name(name: &str) -> BlueprintResult<()> {
241 if name.is_empty() {
242 return Err(BlueprintError::InvalidStyleName {
243 name: name.to_string(),
244 reason: "style name cannot be empty".to_string(),
245 });
246 }
247 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
248 return Err(BlueprintError::InvalidStyleName {
249 name: name.to_string(),
250 reason: "must contain only ASCII alphanumeric characters and underscores".to_string(),
251 });
252 }
253 if name.starts_with(|c: char| c.is_ascii_digit()) {
254 return Err(BlueprintError::InvalidStyleName {
255 name: name.to_string(),
256 reason: "must not start with a digit".to_string(),
257 });
258 }
259 Ok(())
260}
261
262fn validate_mapping_references(
264 md: &crate::template::MarkdownMapping,
265 styles: &IndexMap<String, StyleEntry>,
266) -> BlueprintResult<()> {
267 let fields: &[(&str, &Option<String>)] = &[
268 ("body", &md.body),
269 ("heading1", &md.heading1),
270 ("heading2", &md.heading2),
271 ("heading3", &md.heading3),
272 ("heading4", &md.heading4),
273 ("heading5", &md.heading5),
274 ("heading6", &md.heading6),
275 ("code", &md.code),
276 ("blockquote", &md.blockquote),
277 ("list_item", &md.list_item),
278 ];
279 for &(field_name, ref_opt) in fields {
280 if let Some(style_name) = ref_opt {
281 if !styles.contains_key(style_name) {
282 return Err(BlueprintError::InvalidMappingReference {
283 mapping_field: field_name.to_string(),
284 style_name: style_name.clone(),
285 });
286 }
287 }
288 }
289 Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::style::PartialStyle;
296 use crate::template::TemplateMeta;
297 use hwpforge_foundation::{Alignment, HwpUnit, LineSpacingType};
298 use pretty_assertions::assert_eq;
299
300 fn make_partial_style(font: &str, size_pt: f64) -> PartialStyle {
302 PartialStyle {
303 char_shape: Some(crate::style::PartialCharShape {
304 font: Some(font.to_string()),
305 size: Some(HwpUnit::from_pt(size_pt).unwrap()),
306 ..Default::default()
307 }),
308 para_shape: None,
309 }
310 }
311
312 fn make_template(styles: IndexMap<String, PartialStyle>) -> Template {
314 Template {
315 meta: TemplateMeta {
316 name: "test".to_string(),
317 version: "1.0.0".to_string(),
318 description: None,
319 extends: None,
320 },
321 page: None,
322 styles,
323 markdown_mapping: None,
324 }
325 }
326
327 #[test]
328 fn from_template_single_style() {
329 let mut styles = IndexMap::new();
330 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
331
332 let template = make_template(styles);
333 let registry = StyleRegistry::from_template(&template).unwrap();
334
335 assert_eq!(registry.style_count(), 1);
336 assert_eq!(registry.char_shape_count(), 1);
337 assert_eq!(registry.para_shape_count(), 1);
338 assert_eq!(registry.font_count(), 1);
339
340 let entry = registry.get_style("body").unwrap();
341 assert_eq!(entry.char_shape_id, CharShapeIndex::new(0));
342 assert_eq!(entry.para_shape_id, ParaShapeIndex::new(0));
343 assert_eq!(entry.font_id, FontIndex::new(0));
344 }
345
346 #[test]
347 fn from_template_multiple_styles() {
348 let mut styles = IndexMap::new();
349 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
350 styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
351
352 let template = make_template(styles);
353 let registry = StyleRegistry::from_template(&template).unwrap();
354
355 assert_eq!(registry.style_count(), 2);
356 assert_eq!(registry.char_shape_count(), 2);
357 assert_eq!(registry.para_shape_count(), 2);
358 assert_eq!(registry.font_count(), 2); let body = registry.get_style("body").unwrap();
361 let heading = registry.get_style("heading").unwrap();
362
363 assert_eq!(body.char_shape_id, CharShapeIndex::new(0));
364 assert_eq!(heading.char_shape_id, CharShapeIndex::new(1));
365 }
366
367 #[test]
368 fn font_deduplication_same_font() {
369 let mut styles = IndexMap::new();
370 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
371 styles.insert("heading".to_string(), make_partial_style("Batang", 16.0));
372
373 let template = make_template(styles);
374 let registry = StyleRegistry::from_template(&template).unwrap();
375
376 assert_eq!(registry.font_count(), 1);
378 assert_eq!(registry.fonts[0].as_str(), "Batang");
379
380 let body = registry.get_style("body").unwrap();
382 let heading = registry.get_style("heading").unwrap();
383 assert_eq!(body.font_id, FontIndex::new(0));
384 assert_eq!(heading.font_id, FontIndex::new(0));
385 }
386
387 #[test]
388 fn font_deduplication_different_fonts() {
389 let mut styles = IndexMap::new();
390 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
391 styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
392
393 let template = make_template(styles);
394 let registry = StyleRegistry::from_template(&template).unwrap();
395
396 assert_eq!(registry.font_count(), 2);
398 assert_eq!(registry.fonts[0].as_str(), "Batang");
399 assert_eq!(registry.fonts[1].as_str(), "Dotum");
400
401 let body = registry.get_style("body").unwrap();
403 let heading = registry.get_style("heading").unwrap();
404 assert_eq!(body.font_id, FontIndex::new(0));
405 assert_eq!(heading.font_id, FontIndex::new(1));
406 }
407
408 #[test]
409 fn get_style_by_name() {
410 let mut styles = IndexMap::new();
411 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
412
413 let template = make_template(styles);
414 let registry = StyleRegistry::from_template(&template).unwrap();
415
416 let entry = registry.get_style("body").unwrap();
417 assert_eq!(entry.char_shape_id, CharShapeIndex::new(0));
418
419 assert!(registry.get_style("nonexistent").is_none());
420 }
421
422 #[test]
423 fn char_shape_by_index() {
424 let mut styles = IndexMap::new();
425 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
426
427 let template = make_template(styles);
428 let registry = StyleRegistry::from_template(&template).unwrap();
429
430 let cs = registry.char_shape(CharShapeIndex::new(0)).unwrap();
431 assert_eq!(cs.font, "Batang");
432 assert_eq!(cs.size, HwpUnit::from_pt(10.0).unwrap());
433
434 assert!(registry.char_shape(CharShapeIndex::new(99)).is_none());
435 }
436
437 #[test]
438 fn para_shape_by_index() {
439 let mut styles = IndexMap::new();
440 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
441
442 let template = make_template(styles);
443 let registry = StyleRegistry::from_template(&template).unwrap();
444
445 let ps = registry.para_shape(ParaShapeIndex::new(0)).unwrap();
446 assert_eq!(ps.alignment, Alignment::Left);
448 assert_eq!(ps.line_spacing_type, LineSpacingType::Percentage);
449 assert_eq!(ps.line_spacing_value, 160.0);
450
451 assert!(registry.para_shape(ParaShapeIndex::new(99)).is_none());
452 }
453
454 #[test]
455 fn empty_template_error() {
456 let template = make_template(IndexMap::new());
457
458 let err = StyleRegistry::from_template(&template).unwrap_err();
459 assert!(matches!(err, BlueprintError::EmptyStyleMap));
460 }
461
462 #[test]
463 fn missing_font_error() {
464 let mut styles = IndexMap::new();
465 styles.insert(
466 "broken".to_string(),
467 PartialStyle {
468 char_shape: Some(crate::style::PartialCharShape {
469 font: None, size: Some(HwpUnit::from_pt(10.0).unwrap()),
471 ..Default::default()
472 }),
473 para_shape: None,
474 },
475 );
476
477 let template = make_template(styles);
478 let err = StyleRegistry::from_template(&template).unwrap_err();
479
480 match err {
481 BlueprintError::StyleResolution { style_name, field } => {
482 assert_eq!(style_name, "broken");
483 assert_eq!(field, "font");
484 }
485 other => panic!("unexpected error: {other:?}"),
486 }
487 }
488
489 #[test]
490 fn missing_size_error() {
491 let mut styles = IndexMap::new();
492 styles.insert(
493 "broken".to_string(),
494 PartialStyle {
495 char_shape: Some(crate::style::PartialCharShape {
496 font: Some("Batang".to_string()),
497 size: None, ..Default::default()
499 }),
500 para_shape: None,
501 },
502 );
503
504 let template = make_template(styles);
505 let err = StyleRegistry::from_template(&template).unwrap_err();
506
507 match err {
508 BlueprintError::StyleResolution { style_name, field } => {
509 assert_eq!(style_name, "broken");
510 assert_eq!(field, "size");
511 }
512 other => panic!("unexpected error: {other:?}"),
513 }
514 }
515
516 #[test]
517 fn serde_roundtrip_style_registry() {
518 let mut styles = IndexMap::new();
519 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
520 styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
521
522 let template = make_template(styles);
523 let original = StyleRegistry::from_template(&template).unwrap();
524
525 let yaml = serde_yaml::to_string(&original).unwrap();
526 let back: StyleRegistry = serde_yaml::from_str(&yaml).unwrap();
527
528 assert_eq!(original.font_count(), back.font_count());
529 assert_eq!(original.char_shape_count(), back.char_shape_count());
530 assert_eq!(original.para_shape_count(), back.para_shape_count());
531 assert_eq!(original.style_count(), back.style_count());
532 }
533
534 #[test]
535 fn style_entry_serde_roundtrip() {
536 let entry = StyleEntry {
537 char_shape_id: CharShapeIndex::new(3),
538 para_shape_id: ParaShapeIndex::new(7),
539 font_id: FontIndex::new(1),
540 };
541
542 let json = serde_json::to_string(&entry).unwrap();
543 let back: StyleEntry = serde_json::from_str(&json).unwrap();
544
545 assert_eq!(entry, back);
546 }
547
548 #[test]
549 fn font_count() {
550 let mut styles = IndexMap::new();
551 styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
552 styles.insert("b".to_string(), make_partial_style("Batang", 12.0)); styles.insert("c".to_string(), make_partial_style("Dotum", 10.0)); let template = make_template(styles);
556 let registry = StyleRegistry::from_template(&template).unwrap();
557
558 assert_eq!(registry.font_count(), 2); }
560
561 #[test]
562 fn char_shape_count() {
563 let mut styles = IndexMap::new();
564 styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
565 styles.insert("b".to_string(), make_partial_style("Batang", 12.0));
566
567 let template = make_template(styles);
568 let registry = StyleRegistry::from_template(&template).unwrap();
569
570 assert_eq!(registry.char_shape_count(), 2); }
572
573 #[test]
574 fn para_shape_count() {
575 let mut styles = IndexMap::new();
576 styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
577 styles.insert("b".to_string(), make_partial_style("Dotum", 12.0));
578
579 let template = make_template(styles);
580 let registry = StyleRegistry::from_template(&template).unwrap();
581
582 assert_eq!(registry.para_shape_count(), 2);
583 }
584
585 #[test]
586 fn style_count() {
587 let mut styles = IndexMap::new();
588 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
589 styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
590
591 let template = make_template(styles);
592 let registry = StyleRegistry::from_template(&template).unwrap();
593
594 assert_eq!(registry.style_count(), 2);
595 }
596
597 #[test]
598 fn valid_style_names_accepted() {
599 let mut styles = IndexMap::new();
600 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
601 styles.insert("heading1".to_string(), make_partial_style("Batang", 16.0));
602 styles.insert("_private".to_string(), make_partial_style("Batang", 12.0));
603 styles.insert("my_style_2".to_string(), make_partial_style("Batang", 14.0));
604
605 let template = make_template(styles);
606 assert!(StyleRegistry::from_template(&template).is_ok());
607 }
608
609 #[test]
610 fn invalid_style_name_with_spaces() {
611 let mut styles = IndexMap::new();
612 styles.insert("body style".to_string(), make_partial_style("Batang", 10.0));
613
614 let template = make_template(styles);
615 let err = StyleRegistry::from_template(&template).unwrap_err();
616 assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
617 }
618
619 #[test]
620 fn invalid_style_name_starts_with_digit() {
621 let mut styles = IndexMap::new();
622 styles.insert("1heading".to_string(), make_partial_style("Batang", 10.0));
623
624 let template = make_template(styles);
625 let err = StyleRegistry::from_template(&template).unwrap_err();
626 assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
627 }
628
629 #[test]
630 fn invalid_style_name_special_chars() {
631 let mut styles = IndexMap::new();
632 styles.insert("body-style".to_string(), make_partial_style("Batang", 10.0));
633
634 let template = make_template(styles);
635 let err = StyleRegistry::from_template(&template).unwrap_err();
636 assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
637 }
638
639 #[test]
640 fn markdown_mapping_valid_references() {
641 let mut styles = IndexMap::new();
642 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
643 styles.insert("heading".to_string(), make_partial_style("Batang", 16.0));
644
645 let template = Template {
646 meta: TemplateMeta {
647 name: "test".to_string(),
648 version: "1.0.0".to_string(),
649 description: None,
650 extends: None,
651 },
652 page: None,
653 styles,
654 markdown_mapping: Some(crate::template::MarkdownMapping {
655 body: Some("body".to_string()),
656 heading1: Some("heading".to_string()),
657 ..Default::default()
658 }),
659 };
660
661 let registry = StyleRegistry::from_template(&template).unwrap();
663 assert_eq!(registry.style_count(), 2);
664 }
665
666 #[test]
667 fn markdown_mapping_invalid_reference_error() {
668 let mut styles = IndexMap::new();
669 styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
670
671 let template = Template {
672 meta: TemplateMeta {
673 name: "test".to_string(),
674 version: "1.0.0".to_string(),
675 description: None,
676 extends: None,
677 },
678 page: None,
679 styles,
680 markdown_mapping: Some(crate::template::MarkdownMapping {
681 body: Some("body".to_string()),
682 heading1: Some("nonexistent".to_string()), ..Default::default()
684 }),
685 };
686
687 let err = StyleRegistry::from_template(&template).unwrap_err();
688 match err {
689 BlueprintError::InvalidMappingReference { mapping_field, style_name } => {
690 assert_eq!(mapping_field, "heading1");
691 assert_eq!(style_name, "nonexistent");
692 }
693 other => panic!("Expected InvalidMappingReference, got: {other:?}"),
694 }
695 }
696}