hwpforge_blueprint/
registry.rs

1//! StyleRegistry: converts Template styles into indexed collections.
2//!
3//! The registry performs the final **resolution step** in the Blueprint workflow:
4//!
5//! ```text
6//! Template (YAML, all styles by name)
7//!     |
8//!     v
9//! StyleRegistry::from_template()
10//!     |
11//!     v
12//! StyleRegistry (indexed Vecs: fonts, char_shapes, para_shapes)
13//! ```
14//!
15//! This separation mirrors the **HTML + CSS** model:
16//! - Template = CSS (named styles in human-friendly format)
17//! - StyleRegistry = compiled CSS (numeric indices for runtime efficiency)
18//!
19//! Each style in the template gets allocated sequential indices
20//! (CharShapeIndex, ParaShapeIndex). Fonts are deduplicated: two styles
21//! using "한컴바탕" share a single FontIndex.
22
23use 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/// A resolved style entry with allocated indices.
36///
37/// This is the result of resolving a named style from the Template.
38/// It contains indices pointing into the StyleRegistry's flat collections.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
40#[non_exhaustive]
41pub struct StyleEntry {
42    /// Index into the character shape collection.
43    pub char_shape_id: CharShapeIndex,
44    /// Index into the paragraph shape collection.
45    pub para_shape_id: ParaShapeIndex,
46    /// Index into the font collection (deduplicated).
47    pub font_id: FontIndex,
48}
49
50/// A registry of resolved styles with index-based access.
51///
52/// After inheritance resolution, the Template is converted into a
53/// StyleRegistry where every style is assigned numeric indices for
54/// efficient lookup during document rendering.
55///
56/// # Font Deduplication
57///
58/// Multiple styles can reference the same font. The registry deduplicates
59/// fonts automatically:
60///
61/// ```rust,ignore
62/// // Two styles with the same font → single FontIndex
63/// styles:
64///   body: { font: "Batang", size: 10pt }
65///   heading: { font: "Batang", size: 16pt }
66///
67/// // Registry: fonts = ["Batang"] (index 0)
68/// //           char_shapes[0].font_id = FontIndex(0)
69/// //           char_shapes[1].font_id = FontIndex(0)
70/// ```
71///
72/// # Index Allocation
73///
74/// Indices are allocated sequentially in the order styles appear in the
75/// template (preserving YAML field order via IndexMap):
76/// - CharShape 0, CharShape 1, CharShape 2...
77/// - ParaShape 0, ParaShape 1, ParaShape 2...
78/// - Font 0, Font 1... (deduplicated)
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
80#[non_exhaustive]
81pub struct StyleRegistry {
82    /// All unique fonts referenced by character shapes.
83    pub fonts: Vec<FontId>,
84    /// All resolved character shapes.
85    pub char_shapes: Vec<CharShape>,
86    /// All resolved paragraph shapes.
87    pub para_shapes: Vec<ParaShape>,
88    /// Mapping from style name to its indices (insertion-order preserved).
89    pub style_entries: IndexMap<String, StyleEntry>,
90}
91
92impl StyleRegistry {
93    /// Creates a StyleRegistry from a Template.
94    ///
95    /// This is the **final resolution step**:
96    /// 1. Iterate over template styles (in order)
97    /// 2. Resolve each PartialStyle → CharShape + ParaShape
98    /// 3. Deduplicate fonts
99    /// 4. Allocate sequential indices
100    ///
101    /// # Errors
102    ///
103    /// - [`BlueprintError::EmptyStyleMap`] if the template has no styles
104    /// - [`BlueprintError::StyleResolution`] if any style is missing required fields
105    ///
106    /// # Examples
107    ///
108    /// ```rust,ignore
109    /// use hwpforge_blueprint::{Template, StyleRegistry};
110    ///
111    /// let template = Template::from_yaml(yaml)?;
112    /// let registry = StyleRegistry::from_template(&template)?;
113    ///
114    /// // Access by name
115    /// let body_entry = registry.get_style("body").unwrap();
116    /// let char_shape = registry.char_shape(body_entry.char_shape_id).unwrap();
117    /// assert_eq!(char_shape.font, "한컴바탕");
118    /// ```
119    pub fn from_template(template: &Template) -> BlueprintResult<Self> {
120        if template.styles.is_empty() {
121            return Err(BlueprintError::EmptyStyleMap);
122        }
123
124        // Validate style names
125        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        // Font name → FontIndex mapping for deduplication
135        let mut font_indices: BTreeMap<String, FontIndex> = BTreeMap::new();
136
137        for (style_name, partial_style) in &template.styles {
138            // Resolve character shape (may fail if font/size missing)
139            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            // Deduplicate font: find or allocate FontIndex
149            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            // Resolve paragraph shape (always succeeds with defaults)
163            let partial_para = partial_style.para_shape.as_ref();
164            let para_shape = partial_para.map_or_else(
165                || {
166                    // No para_shape specified → use all defaults
167                    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            // Create style entry
176            style_entries.insert(
177                style_name.clone(),
178                StyleEntry { char_shape_id, para_shape_id, font_id: font_idx },
179            );
180        }
181
182        // Validate markdown mapping references
183        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    /// Looks up a style by name.
191    ///
192    /// Returns `None` if the style name does not exist.
193    pub fn get_style(&self, name: &str) -> Option<&StyleEntry> {
194        self.style_entries.get(name)
195    }
196
197    /// Retrieves a character shape by index.
198    ///
199    /// Returns `None` if the index is out of bounds.
200    pub fn char_shape(&self, idx: CharShapeIndex) -> Option<&CharShape> {
201        self.char_shapes.get(idx.get())
202    }
203
204    /// Retrieves a paragraph shape by index.
205    ///
206    /// Returns `None` if the index is out of bounds.
207    pub fn para_shape(&self, idx: ParaShapeIndex) -> Option<&ParaShape> {
208        self.para_shapes.get(idx.get())
209    }
210
211    /// Retrieves a font by index.
212    ///
213    /// Returns `None` if the index is out of bounds.
214    pub fn font(&self, idx: FontIndex) -> Option<&FontId> {
215        self.fonts.get(idx.get())
216    }
217
218    /// Returns the number of unique fonts.
219    pub fn font_count(&self) -> usize {
220        self.fonts.len()
221    }
222
223    /// Returns the number of character shapes.
224    pub fn char_shape_count(&self) -> usize {
225        self.char_shapes.len()
226    }
227
228    /// Returns the number of paragraph shapes.
229    pub fn para_shape_count(&self) -> usize {
230        self.para_shapes.len()
231    }
232
233    /// Returns the number of named styles.
234    pub fn style_count(&self) -> usize {
235        self.style_entries.len()
236    }
237}
238
239/// Validates a style name: must be non-empty, alphanumeric + underscore, start with letter/underscore.
240fn 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
262/// Validates that all non-None MarkdownMapping references point to existing styles.
263fn 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    // Helper: create a minimal PartialStyle with font + size
301    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    // Helper: create a minimal Template with given styles
313    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); // Different fonts
359
360        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        // 2 styles, same font → 1 FontId
377        assert_eq!(registry.font_count(), 1);
378        assert_eq!(registry.fonts[0].as_str(), "Batang");
379
380        // Both entries point to the same FontIndex
381        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        // 2 styles, different fonts → 2 FontIds
397        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        // Each entry points to its own FontIndex
402        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        // Defaults from PartialParaShape::default().resolve()
447        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, // Missing!
470                    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, // Missing!
498                    ..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)); // Same font
553        styles.insert("c".to_string(), make_partial_style("Dotum", 10.0)); // Different
554
555        let template = make_template(styles);
556        let registry = StyleRegistry::from_template(&template).unwrap();
557
558        assert_eq!(registry.font_count(), 2); // Batang, Dotum
559    }
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); // 2 char shapes even if same font
571    }
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        // Should succeed — all references are valid
662        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()), // Invalid!
683                ..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}