use std::collections::BTreeMap;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use hwpforge_foundation::{CharShapeIndex, FontId, FontIndex, ParaShapeIndex};
use crate::error::{BlueprintError, BlueprintResult};
use crate::style::{CharShape, ParaShape};
use crate::template::Template;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct StyleEntry {
pub char_shape_id: CharShapeIndex,
pub para_shape_id: ParaShapeIndex,
pub font_id: FontIndex,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct StyleRegistry {
pub fonts: Vec<FontId>,
pub char_shapes: Vec<CharShape>,
pub para_shapes: Vec<ParaShape>,
pub style_entries: IndexMap<String, StyleEntry>,
}
impl StyleRegistry {
pub fn from_template(template: &Template) -> BlueprintResult<Self> {
if template.styles.is_empty() {
return Err(BlueprintError::EmptyStyleMap);
}
for name in template.styles.keys() {
validate_style_name(name)?;
}
let mut fonts = Vec::new();
let mut char_shapes = Vec::new();
let mut para_shapes = Vec::new();
let mut style_entries = IndexMap::new();
let mut font_indices: BTreeMap<String, FontIndex> = BTreeMap::new();
for (style_name, partial_style) in &template.styles {
let partial_char = partial_style.char_shape.as_ref().ok_or_else(|| {
BlueprintError::StyleResolution {
style_name: style_name.clone(),
field: "char_shape".to_string(),
}
})?;
let char_shape = partial_char.resolve(style_name)?;
let font_idx = if let Some(&existing_idx) = font_indices.get(&char_shape.font) {
existing_idx
} else {
let font_id = FontId::new(char_shape.font.clone())?;
let new_idx = FontIndex::new(fonts.len());
fonts.push(font_id);
font_indices.insert(char_shape.font.clone(), new_idx);
new_idx
};
let char_shape_id = CharShapeIndex::new(char_shapes.len());
char_shapes.push(char_shape);
let partial_para = partial_style.para_shape.as_ref();
let para_shape = partial_para.map_or_else(
|| {
crate::style::PartialParaShape::default().resolve()
},
|p| p.resolve(),
);
let para_shape_id = ParaShapeIndex::new(para_shapes.len());
para_shapes.push(para_shape);
style_entries.insert(
style_name.clone(),
StyleEntry { char_shape_id, para_shape_id, font_id: font_idx },
);
}
if let Some(ref md) = template.markdown_mapping {
validate_mapping_references(md, &style_entries)?;
}
Ok(StyleRegistry { fonts, char_shapes, para_shapes, style_entries })
}
pub fn get_style(&self, name: &str) -> Option<&StyleEntry> {
self.style_entries.get(name)
}
pub fn char_shape(&self, idx: CharShapeIndex) -> Option<&CharShape> {
self.char_shapes.get(idx.get())
}
pub fn para_shape(&self, idx: ParaShapeIndex) -> Option<&ParaShape> {
self.para_shapes.get(idx.get())
}
pub fn font(&self, idx: FontIndex) -> Option<&FontId> {
self.fonts.get(idx.get())
}
pub fn font_count(&self) -> usize {
self.fonts.len()
}
pub fn char_shape_count(&self) -> usize {
self.char_shapes.len()
}
pub fn para_shape_count(&self) -> usize {
self.para_shapes.len()
}
pub fn style_count(&self) -> usize {
self.style_entries.len()
}
}
fn validate_style_name(name: &str) -> BlueprintResult<()> {
if name.is_empty() {
return Err(BlueprintError::InvalidStyleName {
name: name.to_string(),
reason: "style name cannot be empty".to_string(),
});
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(BlueprintError::InvalidStyleName {
name: name.to_string(),
reason: "must contain only ASCII alphanumeric characters and underscores".to_string(),
});
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err(BlueprintError::InvalidStyleName {
name: name.to_string(),
reason: "must not start with a digit".to_string(),
});
}
Ok(())
}
fn validate_mapping_references(
md: &crate::template::MarkdownMapping,
styles: &IndexMap<String, StyleEntry>,
) -> BlueprintResult<()> {
let fields: &[(&str, &Option<String>)] = &[
("body", &md.body),
("heading1", &md.heading1),
("heading2", &md.heading2),
("heading3", &md.heading3),
("heading4", &md.heading4),
("heading5", &md.heading5),
("heading6", &md.heading6),
("code", &md.code),
("blockquote", &md.blockquote),
("list_item", &md.list_item),
];
for &(field_name, ref_opt) in fields {
if let Some(style_name) = ref_opt {
if !styles.contains_key(style_name) {
return Err(BlueprintError::InvalidMappingReference {
mapping_field: field_name.to_string(),
style_name: style_name.clone(),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::PartialStyle;
use crate::template::TemplateMeta;
use hwpforge_foundation::{Alignment, HwpUnit, LineSpacingType};
use pretty_assertions::assert_eq;
fn make_partial_style(font: &str, size_pt: f64) -> PartialStyle {
PartialStyle {
char_shape: Some(crate::style::PartialCharShape {
font: Some(font.to_string()),
size: Some(HwpUnit::from_pt(size_pt).unwrap()),
..Default::default()
}),
para_shape: None,
}
}
fn make_template(styles: IndexMap<String, PartialStyle>) -> Template {
Template {
meta: TemplateMeta {
name: "test".to_string(),
version: "1.0.0".to_string(),
description: None,
extends: None,
},
page: None,
styles,
markdown_mapping: None,
}
}
#[test]
fn from_template_single_style() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.style_count(), 1);
assert_eq!(registry.char_shape_count(), 1);
assert_eq!(registry.para_shape_count(), 1);
assert_eq!(registry.font_count(), 1);
let entry = registry.get_style("body").unwrap();
assert_eq!(entry.char_shape_id, CharShapeIndex::new(0));
assert_eq!(entry.para_shape_id, ParaShapeIndex::new(0));
assert_eq!(entry.font_id, FontIndex::new(0));
}
#[test]
fn from_template_multiple_styles() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.style_count(), 2);
assert_eq!(registry.char_shape_count(), 2);
assert_eq!(registry.para_shape_count(), 2);
assert_eq!(registry.font_count(), 2);
let body = registry.get_style("body").unwrap();
let heading = registry.get_style("heading").unwrap();
assert_eq!(body.char_shape_id, CharShapeIndex::new(0));
assert_eq!(heading.char_shape_id, CharShapeIndex::new(1));
}
#[test]
fn font_deduplication_same_font() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Batang", 16.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.font_count(), 1);
assert_eq!(registry.fonts[0].as_str(), "Batang");
let body = registry.get_style("body").unwrap();
let heading = registry.get_style("heading").unwrap();
assert_eq!(body.font_id, FontIndex::new(0));
assert_eq!(heading.font_id, FontIndex::new(0));
}
#[test]
fn font_deduplication_different_fonts() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.font_count(), 2);
assert_eq!(registry.fonts[0].as_str(), "Batang");
assert_eq!(registry.fonts[1].as_str(), "Dotum");
let body = registry.get_style("body").unwrap();
let heading = registry.get_style("heading").unwrap();
assert_eq!(body.font_id, FontIndex::new(0));
assert_eq!(heading.font_id, FontIndex::new(1));
}
#[test]
fn get_style_by_name() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
let entry = registry.get_style("body").unwrap();
assert_eq!(entry.char_shape_id, CharShapeIndex::new(0));
assert!(registry.get_style("nonexistent").is_none());
}
#[test]
fn char_shape_by_index() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
let cs = registry.char_shape(CharShapeIndex::new(0)).unwrap();
assert_eq!(cs.font, "Batang");
assert_eq!(cs.size, HwpUnit::from_pt(10.0).unwrap());
assert!(registry.char_shape(CharShapeIndex::new(99)).is_none());
}
#[test]
fn para_shape_by_index() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
let ps = registry.para_shape(ParaShapeIndex::new(0)).unwrap();
assert_eq!(ps.alignment, Alignment::Left);
assert_eq!(ps.line_spacing_type, LineSpacingType::Percentage);
assert_eq!(ps.line_spacing_value, 160.0);
assert!(registry.para_shape(ParaShapeIndex::new(99)).is_none());
}
#[test]
fn empty_template_error() {
let template = make_template(IndexMap::new());
let err = StyleRegistry::from_template(&template).unwrap_err();
assert!(matches!(err, BlueprintError::EmptyStyleMap));
}
#[test]
fn missing_font_error() {
let mut styles = IndexMap::new();
styles.insert(
"broken".to_string(),
PartialStyle {
char_shape: Some(crate::style::PartialCharShape {
font: None, size: Some(HwpUnit::from_pt(10.0).unwrap()),
..Default::default()
}),
para_shape: None,
},
);
let template = make_template(styles);
let err = StyleRegistry::from_template(&template).unwrap_err();
match err {
BlueprintError::StyleResolution { style_name, field } => {
assert_eq!(style_name, "broken");
assert_eq!(field, "font");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn missing_size_error() {
let mut styles = IndexMap::new();
styles.insert(
"broken".to_string(),
PartialStyle {
char_shape: Some(crate::style::PartialCharShape {
font: Some("Batang".to_string()),
size: None, ..Default::default()
}),
para_shape: None,
},
);
let template = make_template(styles);
let err = StyleRegistry::from_template(&template).unwrap_err();
match err {
BlueprintError::StyleResolution { style_name, field } => {
assert_eq!(style_name, "broken");
assert_eq!(field, "size");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn serde_roundtrip_style_registry() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
let template = make_template(styles);
let original = StyleRegistry::from_template(&template).unwrap();
let yaml = serde_yaml::to_string(&original).unwrap();
let back: StyleRegistry = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(original.font_count(), back.font_count());
assert_eq!(original.char_shape_count(), back.char_shape_count());
assert_eq!(original.para_shape_count(), back.para_shape_count());
assert_eq!(original.style_count(), back.style_count());
}
#[test]
fn style_entry_serde_roundtrip() {
let entry = StyleEntry {
char_shape_id: CharShapeIndex::new(3),
para_shape_id: ParaShapeIndex::new(7),
font_id: FontIndex::new(1),
};
let json = serde_json::to_string(&entry).unwrap();
let back: StyleEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, back);
}
#[test]
fn font_count() {
let mut styles = IndexMap::new();
styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
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);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.font_count(), 2); }
#[test]
fn char_shape_count() {
let mut styles = IndexMap::new();
styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
styles.insert("b".to_string(), make_partial_style("Batang", 12.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.char_shape_count(), 2); }
#[test]
fn para_shape_count() {
let mut styles = IndexMap::new();
styles.insert("a".to_string(), make_partial_style("Batang", 10.0));
styles.insert("b".to_string(), make_partial_style("Dotum", 12.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.para_shape_count(), 2);
}
#[test]
fn style_count() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Dotum", 16.0));
let template = make_template(styles);
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.style_count(), 2);
}
#[test]
fn valid_style_names_accepted() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading1".to_string(), make_partial_style("Batang", 16.0));
styles.insert("_private".to_string(), make_partial_style("Batang", 12.0));
styles.insert("my_style_2".to_string(), make_partial_style("Batang", 14.0));
let template = make_template(styles);
assert!(StyleRegistry::from_template(&template).is_ok());
}
#[test]
fn invalid_style_name_with_spaces() {
let mut styles = IndexMap::new();
styles.insert("body style".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let err = StyleRegistry::from_template(&template).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
}
#[test]
fn invalid_style_name_starts_with_digit() {
let mut styles = IndexMap::new();
styles.insert("1heading".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let err = StyleRegistry::from_template(&template).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
}
#[test]
fn invalid_style_name_special_chars() {
let mut styles = IndexMap::new();
styles.insert("body-style".to_string(), make_partial_style("Batang", 10.0));
let template = make_template(styles);
let err = StyleRegistry::from_template(&template).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidStyleName { .. }));
}
#[test]
fn markdown_mapping_valid_references() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
styles.insert("heading".to_string(), make_partial_style("Batang", 16.0));
let template = Template {
meta: TemplateMeta {
name: "test".to_string(),
version: "1.0.0".to_string(),
description: None,
extends: None,
},
page: None,
styles,
markdown_mapping: Some(crate::template::MarkdownMapping {
body: Some("body".to_string()),
heading1: Some("heading".to_string()),
..Default::default()
}),
};
let registry = StyleRegistry::from_template(&template).unwrap();
assert_eq!(registry.style_count(), 2);
}
#[test]
fn markdown_mapping_invalid_reference_error() {
let mut styles = IndexMap::new();
styles.insert("body".to_string(), make_partial_style("Batang", 10.0));
let template = Template {
meta: TemplateMeta {
name: "test".to_string(),
version: "1.0.0".to_string(),
description: None,
extends: None,
},
page: None,
styles,
markdown_mapping: Some(crate::template::MarkdownMapping {
body: Some("body".to_string()),
heading1: Some("nonexistent".to_string()), ..Default::default()
}),
};
let err = StyleRegistry::from_template(&template).unwrap_err();
match err {
BlueprintError::InvalidMappingReference { mapping_field, style_name } => {
assert_eq!(mapping_field, "heading1");
assert_eq!(style_name, "nonexistent");
}
other => panic!("Expected InvalidMappingReference, got: {other:?}"),
}
}
}