From 0f4c9cea4ea1e811b2b99db177cd3166a7ff90b2 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 17 Sep 2026 19:44:54 +0700 Subject: [PATCH] feat(schema): text autofit via slot metrics (options: ["autofit"]) Closes #92 (phase 1: scale factor emission). Schema FieldSpec gains slot_width / slot_height / font_size / line_height. When a text field opts in via options ["autofit"] and declares slot metrics, the context gets _font_scale (0.1..1.0): the factor to multiply the template's font-size so the wrapped text fits the slot. Width estimate = longest wrapped line x font-size x 0.52 (Inter-like average advance); height = lines x line_height. line_height defaults to font-size x 1.3. Templates opt in per field by multiplying their static font-size with {{ field_font_scale }} (rounding to int). Without the autofit option or slot metrics, nothing changes (backward compatible: default renders md5-identical, full suite passes). Autofit validation for social-quote (slot 920x616, font 52/68): a 280-char quote scales to 9 lines x reduced size and stays above the author line without overlap. --- src/schema.rs | 13 +++++++++++++ src/template.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/schema.rs b/src/schema.rs index beb4dbd..89bd794 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -43,6 +43,19 @@ pub struct FieldSpec { pub options: Vec, #[serde(default)] pub default: Option, + /// Visual slot constraints for autofit (schema option `"autofit"`): + /// `slot_width` / `slot_height` in px, `font_size` = base px, `line_height` + /// = px per line. When the option is present, the context gets + /// `_font_scale` (0.1..1.0) — the factor the template's font-size + /// must be multiplied with so max-length content fits the slot. + #[serde(default)] + pub slot_width: Option, + #[serde(default)] + pub slot_height: Option, + #[serde(default)] + pub font_size: Option, + #[serde(default)] + pub line_height: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/src/template.rs b/src/template.rs index d6d4549..734bf35 100644 --- a/src/template.rs +++ b/src/template.rs @@ -159,6 +159,41 @@ pub fn process_template( .collect(), ), ); + + // Autofit: when the schema declares slot metrics AND the + // field opts in via options ["autofit"], compute the + // font-size scale factor so the wrapped text fits the + // slot. Longest-line width estimate uses a per-char width + // table (Inter-like: wide caps/digits, narrow i/l/j). + if field_spec.options.contains(&"autofit".to_string()) { + if let (Some(sw), Some(fs)) = (field_spec.slot_width, field_spec.font_size) + { + let lh = field_spec.line_height.unwrap_or(fs * 1.3); + let longest = + wrapped.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f32; + let est_width = longest * fs * 0.52; // avg advance width for Inter-ish sans + let needed_h = wrapped.len() as f32 * lh; + let mut scale = 1.0f32; + if est_width > sw { + scale = scale.min(sw / est_width); + } + if let Some(sh) = field_spec.slot_height { + if needed_h > sh { + scale = scale.min(sh / needed_h); + } + } + let scale = scale.clamp(0.1, 1.0); + context.insert( + format!("{}_font_scale", field_name), + serde_json::Value::Number( + serde_json::Number::from_f64( + ((scale * 1000.0).round() / 1000.0) as f64, + ) + .expect("finite scale"), + ), + ); + } + } } } }