Skip to main content

mos_fonts/
metrics.rs

1use crate::{Base14Font, Font, extended_glyph_name, normalize::nfc_text, shape, winansi_byte};
2
3/// Advance width of `text` rendered in `font` at `size` points.
4///
5/// Input is normalized through [`crate::nfc_text`] before any width
6/// calculation. Decomposed sequences such as `S\u{0326}` therefore
7/// measure as their precomposed NFC form (`Ș`) in both the Base14
8/// per-character AFM path and the embedded-font shaping path.
9/// [`glyph_width`] delegates here for a one-character string, so it
10/// inherits the same normalization behavior.
11///
12/// For Base14 faces this sums per-character AFM widths (`WinAnsi`
13/// natives + extended Latin reachable via [`extended_glyph_name`]).
14/// Characters outside both tiers: Cyrillic, CJK, emoji: get the
15/// width of `?` (the substitution glyph the PDF emit path also uses
16/// for those characters in Base14 runs). No diagnostic; callers wanting
17/// real coverage should pick an embedded family.
18///
19/// For embedded faces this shapes via `rustybuzz` for glyph selection
20/// and sums the resulting PDF-emittable glyph advances. Positioning
21/// offsets are currently normalized away so layout matches PDF output.
22///
23/// # Examples
24///
25/// ```
26/// use mos_fonts::{Base14Font, Font, text_width};
27///
28/// let width = text_width(Font::Base14(Base14Font::Helvetica), 10.0, "A");
29///
30/// assert_eq!(width, 6.67);
31/// ```
32#[must_use]
33pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
34    let text = nfc_text(text);
35    let text = text.as_ref();
36    match font {
37        Font::Base14(f) => {
38            let mut units: f32 = 0.0;
39            for ch in text.chars() {
40                units += base14_glyph_units(f, ch);
41            }
42            units * size / 1000.0
43        }
44        Font::Embedded(id) => {
45            let ef = id.data();
46            let glyphs = shape(ef, text);
47            let upem = f32::from(ef.units_per_em);
48            glyphs
49                .iter()
50                .map(|g| advance_units_to_pt(g.advance_units, size, upem))
51                .sum()
52        }
53    }
54}
55
56/// Convert a font-unit advance to PDF user-space points.
57///
58/// Values are carried as `i32` because shapers use signed advances. Preserve
59/// sign here so future positioned shaping cannot turn a negative adjustment
60/// into a huge positive width.
61///
62/// # Examples
63///
64/// ```
65/// use mos_fonts::advance_units_to_pt;
66///
67/// assert_eq!(advance_units_to_pt(500, 12.0, 1000.0), 6.0);
68/// assert_eq!(advance_units_to_pt(-500, 12.0, 1000.0), -6.0);
69/// ```
70#[must_use]
71pub fn advance_units_to_pt(advance_units: i32, size_pt: f32, upem: f32) -> f32 {
72    let magnitude = u16::try_from(advance_units.unsigned_abs()).unwrap_or(u16::MAX);
73    let advance = f32::from(magnitude);
74    if advance_units.is_negative() {
75        -advance * size_pt / upem
76    } else {
77        advance * size_pt / upem
78    }
79}
80
81/// Width of a single glyph in `font` at `size` points.
82///
83/// Base14 faces use one AFM lookup; embedded faces shape the single character.
84///
85/// # Examples
86///
87/// ```
88/// use mos_fonts::{Base14Font, Font, glyph_width};
89///
90/// assert_eq!(glyph_width(Font::Base14(Base14Font::Helvetica), 10.0, 'A'), 6.67);
91/// ```
92#[must_use]
93pub fn glyph_width(font: Font, size: f32, ch: char) -> f32 {
94    let mut buf = [0u8; 4];
95    let s = ch.encode_utf8(&mut buf);
96    text_width(font, size, s)
97}
98
99/// Ascender height for `font` at `size` points.
100///
101/// # Examples
102///
103/// ```
104/// use mos_fonts::{Base14Font, Font, ascent};
105///
106/// assert!(ascent(Font::Base14(Base14Font::Helvetica), 10.0) > 0.0);
107/// ```
108#[must_use]
109pub fn ascent(font: Font, size: f32) -> f32 {
110    match font {
111        Font::Base14(f) => f.metrics().ascender * size / 1000.0,
112        Font::Embedded(id) => {
113            let ef = id.data();
114            f32::from(ef.ascender) * size / f32::from(ef.units_per_em)
115        }
116    }
117}
118
119/// Descender depth for `font` at `size` points, as a **positive**
120/// number (the AFM/TTF storage convention is negative; both backends
121/// normalise on the way out).
122///
123/// # Examples
124///
125/// ```
126/// use mos_fonts::{Base14Font, Font, descent};
127///
128/// assert!(descent(Font::Base14(Base14Font::Helvetica), 10.0) > 0.0);
129/// ```
130#[must_use]
131pub fn descent(font: Font, size: f32) -> f32 {
132    match font {
133        Font::Base14(f) => -f.metrics().descender * size / 1000.0,
134        Font::Embedded(id) => {
135            let ef = id.data();
136            -f32::from(ef.descender) * size / f32::from(ef.units_per_em)
137        }
138    }
139}
140
141/// Width of a single character in a Base14 face, in 1/1000 em. `WinAnsi`
142/// natives go through the baked O(1) table; extended glyphs (Latin
143/// Extended-A, math operators, ligatures) go through the baked sorted
144/// name index. Anything else (Cyrillic, CJK, emoji) silently returns
145/// the width of `?`; the PDF emit path renders those characters as
146/// `?` too, so widths and content stream stay in sync. Embedded
147/// families exist precisely so callers wanting real coverage can opt
148/// out of this `?`-everywhere behaviour.
149fn base14_glyph_units(face: Base14Font, ch: char) -> f32 {
150    if matches!(face, Base14Font::Symbol | Base14Font::ZapfDingbats) {
151        // Symbol/Dingbats don't carry WinAnsi widths. The layout
152        // engine doesn't route runs into them today; treat as 0
153        // rather than panic.
154        return 0.0;
155    }
156    if let Some(byte) = winansi_byte(ch) {
157        return face.winansi_width(byte).unwrap_or(0.0);
158    }
159    if let Some(name) = extended_glyph_name(ch)
160        && let Some(w) = face.glyph_width_by_name(name)
161    {
162        return w;
163    }
164    // Fallback: width of `?` (WinAnsi byte 0x3F). Always present in
165    // every Latin Core 14 face.
166    face.winansi_width(b'?').unwrap_or(0.0)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::EmbeddedFontId;
173
174    const HELV: Font = Font::Base14(Base14Font::Helvetica);
175    const HELV_BOLD: Font = Font::Base14(Base14Font::HelveticaBold);
176    const HELV_OBLIQUE: Font = Font::Base14(Base14Font::HelveticaOblique);
177    const COURIER: Font = Font::Base14(Base14Font::Courier);
178
179    #[test]
180    fn helvetica_space_width_is_278_thou_em() {
181        let w = text_width(HELV, 1000.0, " ");
182        assert!((w - 278.0).abs() < 1e-6);
183    }
184
185    #[test]
186    fn helvetica_apostrophe_matches_afm() {
187        let w = text_width(HELV, 1000.0, "'");
188        assert!((w - 191.0).abs() < 1e-6, "got {w}");
189    }
190
191    #[test]
192    fn courier_is_monospace() {
193        let a = text_width(COURIER, 12.0, "a");
194        let m = text_width(COURIER, 12.0, "M");
195        assert!((a - m).abs() < f32::EPSILON);
196    }
197
198    #[test]
199    fn bold_is_wider_than_regular_for_caps() {
200        let r = text_width(HELV, 100.0, "B");
201        let b = text_width(HELV_BOLD, 100.0, "B");
202        assert!(b > r);
203    }
204
205    #[test]
206    fn helvetica_capital_a_matches_adobe_core14_afm() {
207        let w = text_width(HELV, 1000.0, "A");
208        assert!((w - 667.0).abs() < 1e-3, "got {w}");
209        let wo = text_width(HELV_OBLIQUE, 1000.0, "A");
210        assert!((wo - 667.0).abs() < 1e-3, "got {wo}");
211        let wb = text_width(HELV_BOLD, 1000.0, "A");
212        assert!((wb - 722.0).abs() < 1e-3, "got {wb}");
213    }
214
215    #[test]
216    fn helvetica_eacute_matches_adobe_core14_afm() {
217        let lower = text_width(HELV, 1000.0, "é");
218        assert!((lower - 556.0).abs() < 1e-3, "got {lower}");
219        let upper = text_width(HELV, 1000.0, "É");
220        assert!((upper - 667.0).abs() < 1e-3, "got {upper}");
221    }
222
223    #[test]
224    fn base14_non_winansi_falls_back_to_question_mark_silently() {
225        // Cyrillic П has no glyph in any Base14 face. The width path
226        // returns the width of `?` (so width measurements stay
227        // consistent with the rendered output) and emits no diagnostic.
228        // PDF emission renders `?` for the same character.
229        let q = text_width(HELV, 1000.0, "?");
230        let cyrillic = text_width(HELV, 1000.0, "П");
231        assert!((q - cyrillic).abs() < 1e-3, "q={q} cyr={cyrillic}");
232    }
233
234    #[test]
235    fn helvetica_lslash_resolves_through_extended_glyph_name_lookup() {
236        let w = text_width(HELV, 1000.0, "ł");
237        assert!((w - 222.0).abs() < 1e-3, "got {w}");
238        let lodz = text_width(HELV, 1000.0, "Łódź");
239        assert!(
240            (lodz - (556.0 + 556.0 + 556.0 + 500.0)).abs() < 1e-3,
241            "got {lodz}"
242        );
243    }
244
245    #[test]
246    fn embedded_text_width_is_nonzero_for_cyrillic() {
247        // The whole point: scripts the Base14 fonts can't render get
248        // real widths through the embedded path.
249        let font = Font::Embedded(EmbeddedFontId::Regular);
250        let w = text_width(font, 12.0, "Привет");
251        assert!(w > 0.0);
252    }
253
254    #[test]
255    fn embedded_text_width_normalizes_decomposed_romanian() {
256        let font = Font::Embedded(EmbeddedFontId::Regular);
257        let decomposed = text_width(font, 12.0, "S\u{0326}");
258        let precomposed = text_width(font, 12.0, "\u{0218}");
259
260        assert!((decomposed - precomposed).abs() < f32::EPSILON);
261    }
262
263    #[test]
264    fn advance_units_to_pt_preserves_negative_sign() {
265        let actual = advance_units_to_pt(-1000, 12.0, 1000.0);
266        assert!((actual + 12.0).abs() < f32::EPSILON, "got {actual}");
267    }
268}