Skip to main content

xpict_core/
font.rs

1//! Bundled Liberation Sans outlines (`ttf-parser`).
2//!
3//! Replaces Python fontTools for advances, ink bounds, and glyph contours.
4//! Contours are sampled (8 steps per curve) to match the old ContourPen, then
5//! assembled with even-odd fill into [`crate::geom::Shape`].
6//!
7//! Chem scripts (H-counts, charges) are **fake** sub/superscripts: scale by
8//! [`crate::metrics::SCRIPT_SCALE`], buffer the outline to restore stem weight,
9//! and cache the result. Dummy ``*`` uses a custom star at [`STAR_FRAC`].
10
11#![cfg(feature = "font")]
12
13use std::collections::HashMap;
14use std::sync::{Mutex, OnceLock};
15
16use ttf_parser::{Face, GlyphId, OutlineBuilder, Rect};
17
18use crate::geom::Shape;
19use crate::metrics::{SCRIPT_SCALE, STAR_FRAC};
20
21const BEZIER_STEPS: usize = 8;
22
23const FONT_REGULAR: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
24const FONT_BOLD: &[u8] = include_bytes!("../fonts/LiberationSans-Bold.ttf");
25const FONT_ITALIC: &[u8] = include_bytes!("../fonts/LiberationSans-Italic.ttf");
26const FONT_BOLD_ITALIC: &[u8] = include_bytes!("../fonts/LiberationSans-BoldItalic.ttf");
27
28/// Liberation Sans face variant.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30pub enum FaceStyle {
31    #[default]
32    Regular,
33    Bold,
34    Italic,
35    BoldItalic,
36}
37
38impl FaceStyle {
39    pub fn from_flags(bold: bool, italic: bool) -> Self {
40        match (bold, italic) {
41            (true, true) => Self::BoldItalic,
42            (true, false) => Self::Bold,
43            (false, true) => Self::Italic,
44            (false, false) => Self::Regular,
45        }
46    }
47
48    fn bytes(self) -> &'static [u8] {
49        match self {
50            Self::Regular => FONT_REGULAR,
51            Self::Bold => FONT_BOLD,
52            Self::Italic => FONT_ITALIC,
53            Self::BoldItalic => FONT_BOLD_ITALIC,
54        }
55    }
56}
57
58/// Typographic constants in em units (font +Y up).
59#[derive(Debug, Clone, Copy)]
60pub struct FaceMetrics {
61    pub upem: f64,
62    pub ascent: f64,
63    pub descent: f64,
64    pub line_gap: f64,
65    pub cap_height: f64,
66    pub x_height: f64,
67    pub stem_em: f64,
68}
69
70/// One character metrics in em units (font +Y up).
71#[derive(Debug, Clone, Copy)]
72pub struct GlyphMetrics {
73    pub advance: f64,
74    pub ink_xmin: Option<f64>,
75    pub ink_ymin: Option<f64>,
76    pub ink_xmax: Option<f64>,
77    pub ink_ymax: Option<f64>,
78}
79
80impl GlyphMetrics {
81    pub fn has_ink(&self) -> bool {
82        self.ink_xmin.is_some()
83    }
84
85    pub fn ink_width(&self) -> f64 {
86        match (self.ink_xmin, self.ink_xmax) {
87            (Some(a), Some(b)) => b - a,
88            _ => 0.0,
89        }
90    }
91
92    pub fn ink_height(&self) -> f64 {
93        match (self.ink_ymin, self.ink_ymax) {
94            (Some(a), Some(b)) => b - a,
95            _ => 0.0,
96        }
97    }
98}
99
100fn face(style: FaceStyle) -> Face<'static> {
101    Face::parse(style.bytes(), 0).expect("bundled Liberation Sans TTF")
102}
103
104/// Face metrics for a Liberation Sans style.
105pub fn face_metrics(style: FaceStyle) -> FaceMetrics {
106    let f = face(style);
107    let upem = f64::from(f.units_per_em());
108    let mut cap = f.capital_height().map(f64::from).unwrap_or(0.0);
109    let mut xh = f.x_height().map(f64::from).unwrap_or(0.0);
110    if cap <= 0.0 {
111        if let Some(g) = glyph_metrics('H', style) {
112            cap = g.ink_ymax.unwrap_or(0.0);
113        }
114    }
115    if xh <= 0.0 {
116        if let Some(g) = glyph_metrics('x', style) {
117            xh = g.ink_ymax.unwrap_or(0.0);
118        }
119    }
120    FaceMetrics {
121        upem,
122        ascent: f64::from(f.ascender()),
123        descent: f64::from(f.descender()),
124        line_gap: f64::from(f.line_gap()),
125        cap_height: cap,
126        x_height: xh,
127        stem_em: measure_stem_em(style),
128    }
129}
130
131/// Metrics for a single Unicode scalar.
132pub fn glyph_metrics(ch: char, style: FaceStyle) -> Option<GlyphMetrics> {
133    let f = face(style);
134    let upem = f64::from(f.units_per_em());
135    let Some(gid) = f.glyph_index(ch) else {
136        return Some(GlyphMetrics {
137            advance: 0.5 * upem,
138            ink_xmin: None,
139            ink_ymin: None,
140            ink_xmax: None,
141            ink_ymax: None,
142        });
143    };
144    let advance = f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
145    let ink = f.glyph_bounding_box(gid).map(rect_to_ink);
146    Some(GlyphMetrics {
147        advance,
148        ink_xmin: ink.map(|r| r.0),
149        ink_ymin: ink.map(|r| r.1),
150        ink_xmax: ink.map(|r| r.2),
151        ink_ymax: ink.map(|r| r.3),
152    })
153}
154
155fn rect_to_ink(r: Rect) -> (f64, f64, f64, f64) {
156    (
157        f64::from(r.x_min),
158        f64::from(r.y_min),
159        f64::from(r.x_max),
160        f64::from(r.y_max),
161    )
162}
163
164struct ContourCollector {
165    contours: Vec<Vec<(f64, f64)>>,
166    cur: Vec<(f64, f64)>,
167}
168
169impl ContourCollector {
170    fn new() -> Self {
171        Self {
172            contours: Vec::new(),
173            cur: Vec::new(),
174        }
175    }
176
177    fn finish_contour(&mut self) {
178        if self.cur.len() >= 3 {
179            self.contours.push(std::mem::take(&mut self.cur));
180        } else {
181            self.cur.clear();
182        }
183    }
184}
185
186impl OutlineBuilder for ContourCollector {
187    fn move_to(&mut self, x: f32, y: f32) {
188        self.finish_contour();
189        self.cur = vec![(f64::from(x), f64::from(y))];
190    }
191
192    fn line_to(&mut self, x: f32, y: f32) {
193        self.cur.push((f64::from(x), f64::from(y)));
194    }
195
196    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
197        let (x0, y0) = *self.cur.last().expect("quad_to without move_to");
198        let (x1, y1, x, y) = (f64::from(x1), f64::from(y1), f64::from(x), f64::from(y));
199        for i in 1..=BEZIER_STEPS {
200            let t = i as f64 / BEZIER_STEPS as f64;
201            let mt = 1.0 - t;
202            self.cur.push((
203                mt * mt * x0 + 2.0 * mt * t * x1 + t * t * x,
204                mt * mt * y0 + 2.0 * mt * t * y1 + t * t * y,
205            ));
206        }
207    }
208
209    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
210        let (x0, y0) = *self.cur.last().expect("curve_to without move_to");
211        let (x1, y1, x2, y2, x, y) = (
212            f64::from(x1),
213            f64::from(y1),
214            f64::from(x2),
215            f64::from(y2),
216            f64::from(x),
217            f64::from(y),
218        );
219        for i in 1..=BEZIER_STEPS {
220            let t = i as f64 / BEZIER_STEPS as f64;
221            let mt = 1.0 - t;
222            self.cur.push((
223                mt * mt * mt * x0
224                    + 3.0 * mt * mt * t * x1
225                    + 3.0 * mt * t * t * x2
226                    + t * t * t * x,
227                mt * mt * mt * y0
228                    + 3.0 * mt * mt * t * y1
229                    + 3.0 * mt * t * t * y2
230                    + t * t * t * y,
231            ));
232        }
233    }
234
235    fn close(&mut self) {
236        self.finish_contour();
237    }
238}
239
240fn outline_glyph(face: &Face<'_>, gid: GlyphId) -> Vec<Vec<(f64, f64)>> {
241    let mut collector = ContourCollector::new();
242    let _ = face.outline_glyph(gid, &mut collector);
243    collector.finish_contour();
244    collector.contours
245}
246
247/// Outline plain Unicode in font space (+Y up); return (shape, advance_em).
248pub fn outline_run_em(text: &str, style: FaceStyle) -> (Option<Shape>, f64) {
249    if text.is_empty() {
250        return (None, 0.0);
251    }
252    let f = face(style);
253    let upem = f64::from(f.units_per_em());
254    let mut contours: Vec<Vec<(f64, f64)>> = Vec::new();
255    let mut pen_x = 0.0;
256    for ch in text.chars() {
257        let Some(gid) = f.glyph_index(ch) else {
258            pen_x += 0.5 * upem;
259            continue;
260        };
261        for mut contour in outline_glyph(&f, gid) {
262            for p in &mut contour {
263                p.0 += pen_x;
264            }
265            contours.push(contour);
266        }
267        pen_x += f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
268    }
269    if contours.is_empty() {
270        return (None, pen_x);
271    }
272    let shape = Shape::from_contours_evenodd(&contours);
273    if shape.is_empty() {
274        (None, pen_x)
275    } else {
276        (Some(shape), pen_x)
277    }
278}
279
280/// Sub / super / normal draw role for chem label glyphs.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub enum ScriptRole {
283    Normal,
284    Subscript,
285    Superscript,
286}
287
288/// One chem-label glyph with script role and face (markup bold/italic).
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct ChemGlyph {
291    pub ch: char,
292    pub role: ScriptRole,
293    pub face: FaceStyle,
294}
295
296fn script_cache() -> &'static Mutex<HashMap<(char, FaceStyle, ScriptRole), (Option<Shape>, f64)>> {
297    static CACHE: OnceLock<Mutex<HashMap<(char, FaceStyle, ScriptRole), (Option<Shape>, f64)>>> =
298        OnceLock::new();
299    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
300}
301
302/// Outline one character in em space (+Y up), optionally as a fake script.
303///
304/// Scripts: scale by [`SCRIPT_SCALE`], then buffer so stem weight stays close
305/// to the full-size glyph. Results are memoized.
306pub fn outline_glyph_em(ch: char, style: FaceStyle, role: ScriptRole) -> (Option<Shape>, f64) {
307    if let Ok(guard) = script_cache().lock() {
308        if let Some(hit) = guard.get(&(ch, style, role)) {
309            return hit.clone();
310        }
311    }
312    let built = outline_glyph_em_uncached(ch, style, role);
313    if let Ok(mut guard) = script_cache().lock() {
314        guard.insert((ch, style, role), built.clone());
315    }
316    built
317}
318
319fn outline_glyph_em_uncached(
320    ch: char,
321    style: FaceStyle,
322    role: ScriptRole,
323) -> (Option<Shape>, f64) {
324    let f = face(style);
325    let upem = f64::from(f.units_per_em());
326    let Some(gid) = f.glyph_index(ch) else {
327        return (None, 0.5 * upem);
328    };
329    let advance = f64::from(f.glyph_hor_advance(gid).unwrap_or(0));
330    let contours = outline_glyph(&f, gid);
331    if contours.is_empty() {
332        return (None, advance);
333    }
334    let shape = Shape::from_contours_evenodd(&contours);
335    if shape.is_empty() {
336        return (None, advance);
337    }
338    match role {
339        ScriptRole::Normal => (Some(shape), advance),
340        ScriptRole::Subscript | ScriptRole::Superscript => {
341            // Scale about the glyph origin; advance tracks the smaller width.
342            let scaled = shape.scale(SCRIPT_SCALE, SCRIPT_SCALE, 0.0, 0.0);
343            // Inflate so 0.66× glyphs keep roughly full stem weight.
344            let stem = face_metrics(style).stem_em;
345            let grow = 0.5 * (1.0 - SCRIPT_SCALE) * stem * upem;
346            let thick = if grow > 0.05 {
347                scaled.buffer(grow)
348            } else {
349                scaled
350            };
351            (Some(thick), advance * SCRIPT_SCALE)
352        }
353    }
354}
355
356/// Custom five-point star in em units (centered on advance, mid cap-height).
357///
358/// Liberation's asterisk is too small / high for R-group markers. Size is
359/// driven by [`STAR_FRAC`] × cap-height (outer diameter).
360pub fn outline_star_em(style: FaceStyle) -> (Shape, f64) {
361    let face_m = face_metrics(style);
362    let height = STAR_FRAC * face_m.cap_height;
363    let outer = 0.5 * height;
364    let inner = outer * 0.38;
365    let pad = 0.12 * face_m.cap_height;
366    let advance = height + pad;
367    let cx = 0.5 * advance;
368    let cy = 0.5 * face_m.cap_height;
369    let mut ring = Vec::with_capacity(10);
370    for i in 0..10 {
371        let a = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
372        let r = if i % 2 == 0 { outer } else { inner };
373        ring.push((cx + r * a.cos(), cy + r * a.sin()));
374    }
375    let shape = Shape::from_ring(&ring);
376    (shape, advance)
377}
378
379/// Outline a chem glyph run in em space (+Y up). Returns `(shape, advance_em)`.
380///
381/// Each glyph uses its own [`ChemGlyph::face`]. `style` is a fallback metrics
382/// face (cap-height for script shifts) when the run is empty of faces — callers
383/// usually pass the molecule default.
384///
385/// Subscripts drop by ~⅓ cap-height; superscripts rise by ~½ (RDKit-ish).
386pub fn outline_chem_run_em(glyphs: &[ChemGlyph], style: FaceStyle) -> (Option<Shape>, f64) {
387    if glyphs.is_empty() {
388        return (None, 0.0);
389    }
390    // Lone ``*`` → custom star.
391    if glyphs.len() == 1 && glyphs[0].ch == '*' && glyphs[0].role == ScriptRole::Normal {
392        let (s, adv) = outline_star_em(glyphs[0].face);
393        return (if s.is_empty() { None } else { Some(s) }, adv);
394    }
395    let metrics_face = glyphs.first().map(|g| g.face).unwrap_or(style);
396    let face_m = face_metrics(metrics_face);
397    let sub_dy = -0.33 * face_m.cap_height;
398    let super_dy = 0.50 * face_m.cap_height;
399    let mut pen_x = 0.0;
400    let mut acc: Option<Shape> = None;
401    for g in glyphs {
402        let (piece, adv) = if g.ch == '*' && g.role == ScriptRole::Normal {
403            let (s, a) = outline_star_em(g.face);
404            (Some(s), a)
405        } else {
406            outline_glyph_em(g.ch, g.face, g.role)
407        };
408        let dy = match g.role {
409            ScriptRole::Normal => 0.0,
410            ScriptRole::Subscript => sub_dy,
411            ScriptRole::Superscript => super_dy,
412        };
413        if let Some(p) = piece.filter(|s| !s.is_empty()) {
414            let placed = p.translate(pen_x, dy);
415            acc = Some(match acc.take() {
416                Some(a) => a.union(&placed),
417                None => placed,
418            });
419        }
420        pen_x += adv;
421    }
422    (acc, pen_x)
423}
424
425/// Place a chem glyph run in SVG space (+Y down) with baseline at `y`.
426///
427/// `anchor`: ``start`` / ``middle`` / ``end`` on the advance box.
428pub fn compile_chem_shapes(
429    glyphs: &[ChemGlyph],
430    x: f64,
431    y: f64,
432    font_size: f64,
433    anchor: &str,
434    style: FaceStyle,
435) -> Option<Shape> {
436    if glyphs.is_empty() {
437        return None;
438    }
439    let face_m = face_metrics(style);
440    let scale = font_size / face_m.upem;
441    let (geom_em, advance_em) = outline_chem_run_em(glyphs, style);
442    let advance = advance_em * scale;
443    let origin_x = match anchor {
444        "end" => x - advance,
445        "start" | "left" => x,
446        _ => x - 0.5 * advance,
447    };
448    let geom_em = geom_em?;
449    // Scripts: RDKit shifts sub down / super up by ~½ of the preceding glyph height.
450    // We bake a vertical nudge per glyph when assembling horizontal runs in labels.
451    let geom = geom_em
452        .scale(scale, -scale, 0.0, 0.0)
453        .translate(origin_x, y);
454    if geom.is_empty() {
455        None
456    } else {
457        Some(geom)
458    }
459}
460
461/// Compile plain text to SVG-space glyph geometry (+Y down).
462pub fn compile_text_shapes(
463    text: &str,
464    x: f64,
465    y: f64,
466    font_size: f64,
467    anchor: &str,
468    style: FaceStyle,
469) -> Option<Shape> {
470    if text.is_empty() {
471        return None;
472    }
473    let glyphs: Vec<ChemGlyph> = text
474        .chars()
475        .map(|ch| ChemGlyph {
476            ch,
477            role: ScriptRole::Normal,
478            face: style,
479        })
480        .collect();
481    compile_chem_shapes(&glyphs, x, y, font_size, anchor, style)
482}
483
484fn measure_stem_em(style: FaceStyle) -> f64 {
485    let f = face(style);
486    let upem = f64::from(f.units_per_em());
487    let Some(gid) = f.glyph_index('H') else {
488        return 0.0933;
489    };
490    let contours = outline_glyph(&f, gid);
491    if contours.is_empty() {
492        return 0.0933;
493    }
494    let geom = Shape::from_contours_evenodd(&contours);
495    let Some((_minx, miny, _maxx, maxy)) = geom.bounds() else {
496        return 0.0933;
497    };
498    let y = miny + 0.75 * (maxy - miny);
499    let span = geom.horizontal_span_at(y);
500    if span <= 0.0 {
501        0.0933
502    } else {
503        span / upem
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::metrics::{FONT_PX, FONT_STEM_EM, SCRIPT_SCALE, STAR_FRAC};
511
512    #[test]
513    fn liberation_face_metrics() {
514        let m = face_metrics(FaceStyle::Regular);
515        assert_eq!(m.upem, 2048.0);
516        assert!((m.ascent - 1854.0).abs() < 1e-6);
517        assert!((m.descent - -434.0).abs() < 1e-6);
518        assert!((m.stem_em - FONT_STEM_EM).abs() / FONT_STEM_EM < 0.05);
519        assert!(m.cap_height > 0.5 * m.upem);
520    }
521
522    #[test]
523    fn glyph_h_and_o() {
524        let h = glyph_metrics('H', FaceStyle::Regular).unwrap();
525        assert!((h.advance - 1479.0).abs() < 1e-6);
526        assert!(h.has_ink());
527        assert!(h.ink_height() > 1000.0);
528        let o = glyph_metrics('O', FaceStyle::Regular).unwrap();
529        assert!(o.ink_width() > o.advance * 0.5);
530    }
531
532    #[test]
533    fn outline_o_has_hole() {
534        let (shape, adv) = outline_run_em("O", FaceStyle::Regular);
535        let shape = shape.expect("O outline");
536        assert!(shape.has_holes());
537        assert!(adv > 0.0);
538        assert!(!shape.contains(
539            shape.centroid().unwrap().0,
540            shape.centroid().unwrap().1
541        ));
542    }
543
544    #[test]
545    fn compile_svg_space_label() {
546        let shape = compile_text_shapes("OH", 100.0, 50.0, FONT_PX, "middle", FaceStyle::Regular)
547            .expect("OH");
548        assert!(!shape.is_empty());
549        let d = shape.to_svg_d();
550        assert!(d.contains('M') && d.contains('Z'));
551    }
552
553    #[test]
554    fn script_glyph_is_smaller_but_thickened() {
555        let (full, adv_full) = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Normal);
556        let (sub, adv_sub) = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
557        let full = full.expect("2");
558        let sub = sub.expect("2 sub");
559        assert!(adv_sub < adv_full * 0.75);
560        assert!(sub.area() > 0.0);
561        // Buffered script should not collapse to a speck.
562        assert!(sub.area() > full.area() * SCRIPT_SCALE * SCRIPT_SCALE * 0.5);
563    }
564
565    #[test]
566    fn star_is_larger_than_asterisk_advance() {
567        let (_ast, adv_ast) = outline_run_em("*", FaceStyle::Regular);
568        let (star, adv_star) = outline_star_em(FaceStyle::Regular);
569        assert!(adv_star > adv_ast * 1.2);
570        let face = face_metrics(FaceStyle::Regular);
571        let (_x0, y0, _x1, y1) = star.bounds().expect("star ink");
572        let height = y1 - y0;
573        // Custom star targets STAR_FRAC × cap-height; allow simplify slack.
574        assert!(height > 1.7 * face.cap_height);
575        assert!(height < 2.3 * face.cap_height);
576        let _ = STAR_FRAC;
577    }
578
579    #[test]
580    fn face_style_flags_and_variants() {
581        assert_eq!(FaceStyle::from_flags(false, false), FaceStyle::Regular);
582        assert_eq!(FaceStyle::from_flags(true, false), FaceStyle::Bold);
583        assert_eq!(FaceStyle::from_flags(false, true), FaceStyle::Italic);
584        assert_eq!(FaceStyle::from_flags(true, true), FaceStyle::BoldItalic);
585        for style in [
586            FaceStyle::Bold,
587            FaceStyle::Italic,
588            FaceStyle::BoldItalic,
589        ] {
590            let m = face_metrics(style);
591            assert_eq!(m.upem, 2048.0);
592            assert!(m.stem_em > 0.05);
593        }
594    }
595
596    #[test]
597    fn glyph_metrics_missing_and_empty_ink() {
598        let missing = glyph_metrics('\u{10FFFF}', FaceStyle::Regular).unwrap();
599        assert!(!missing.has_ink());
600        assert_eq!(missing.ink_width(), 0.0);
601        assert_eq!(missing.ink_height(), 0.0);
602        assert!(missing.advance > 0.0);
603
604        let (none, adv) = outline_run_em("", FaceStyle::Regular);
605        assert!(none.is_none());
606        assert_eq!(adv, 0.0);
607
608        // Unknown scalar advances without contours.
609        let (shape, adv2) = outline_run_em("\u{10FFFF}", FaceStyle::Regular);
610        assert!(shape.is_none());
611        assert!(adv2 > 0.0);
612    }
613
614    #[test]
615    fn chem_run_scripts_anchors_and_empty() {
616        assert!(outline_chem_run_em(&[], FaceStyle::Regular).0.is_none());
617        assert!(compile_chem_shapes(&[], 0.0, 0.0, FONT_PX, "middle", FaceStyle::Regular).is_none());
618        assert!(compile_text_shapes("", 0.0, 0.0, FONT_PX, "middle", FaceStyle::Regular).is_none());
619
620        let glyphs = [
621            ChemGlyph {
622                ch: 'N',
623                role: ScriptRole::Normal,
624                face: FaceStyle::Regular,
625            },
626            ChemGlyph {
627                ch: '2',
628                role: ScriptRole::Subscript,
629                face: FaceStyle::Regular,
630            },
631            ChemGlyph {
632                ch: '+',
633                role: ScriptRole::Superscript,
634                face: FaceStyle::Regular,
635            },
636        ];
637        let (shape, adv) = outline_chem_run_em(&glyphs, FaceStyle::Regular);
638        assert!(shape.is_some());
639        assert!(adv > 0.0);
640
641        let start = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "start", FaceStyle::Regular)
642            .expect("start");
643        let end = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "end", FaceStyle::Regular)
644            .expect("end");
645        let mid = compile_chem_shapes(&glyphs, 50.0, 20.0, FONT_PX, "middle", FaceStyle::Regular)
646            .expect("mid");
647        assert!(!start.is_empty() && !end.is_empty() && !mid.is_empty());
648
649        // Star mixed into a chem run.
650        let star_run = [ChemGlyph {
651            ch: '*',
652            role: ScriptRole::Normal,
653            face: FaceStyle::Regular,
654        }];
655        let (s, _) = outline_chem_run_em(&star_run, FaceStyle::Regular);
656        assert!(s.unwrap().area() > 0.0);
657    }
658
659    #[test]
660    fn script_cache_hit_and_missing_in_chem_run() {
661        let a = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
662        let b = outline_glyph_em('2', FaceStyle::Regular, ScriptRole::Subscript);
663        assert_eq!(a.1, b.1);
664        assert!(a.0.is_some());
665
666        // Missing scalar + star in one run (skips empty piece, keeps star).
667        let glyphs = [
668            ChemGlyph {
669                ch: '\u{10FFFF}',
670                role: ScriptRole::Normal,
671                face: FaceStyle::Regular,
672            },
673            ChemGlyph {
674                ch: '*',
675                role: ScriptRole::Normal,
676                face: FaceStyle::Regular,
677            },
678        ];
679        let (shape, adv) = outline_chem_run_em(&glyphs, FaceStyle::Regular);
680        assert!(shape.is_some());
681        assert!(adv > 0.0);
682
683        // Missing-only chem glyph (None shape, positive advance).
684        let miss = outline_glyph_em('\u{10FFFF}', FaceStyle::Bold, ScriptRole::Normal);
685        assert!(miss.0.is_none());
686        assert!(miss.1 > 0.0);
687    }
688
689    #[test]
690    fn contour_collector_cubic_curve_to() {
691        // Liberation outlines are quadratic-only; exercise the cubic arm directly.
692        let mut c = ContourCollector::new();
693        c.move_to(0.0, 0.0);
694        c.curve_to(10.0, 0.0, 10.0, 10.0, 0.0, 10.0);
695        c.close();
696        assert_eq!(c.contours.len(), 1);
697        assert_eq!(
698            c.contours[0].len(),
699            1 + BEZIER_STEPS,
700            "move_to + one sample per Bezier step"
701        );
702        // Midpoint sample at t=0.5 (step BEZIER_STEPS/2).
703        let t = 0.5;
704        let mt = 1.0 - t;
705        let expected = (
706            mt * mt * mt * 0.0
707                + 3.0 * mt * mt * t * 10.0
708                + 3.0 * mt * t * t * 10.0
709                + t * t * t * 0.0,
710            mt * mt * mt * 0.0
711                + 3.0 * mt * mt * t * 0.0
712                + 3.0 * mt * t * t * 10.0
713                + t * t * t * 10.0,
714        );
715        let mid_idx = BEZIER_STEPS / 2;
716        let (mx, my) = c.contours[0][mid_idx];
717        assert!(
718            (mx - expected.0).abs() < 1e-9 && (my - expected.1).abs() < 1e-9,
719            "midpoint ({mx}, {my}) vs cubic@0.5 ({}, {})",
720            expected.0,
721            expected.1
722        );
723    }
724}