iced_math 0.4.0

Native LaTeX math widget for Iced 0.14 — pure Rust, zero JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Bundled math font parsing.

use std::fmt::Write;
use std::sync::OnceLock;
use ttf_parser::Face;
pub use ttf_parser::GlyphId;

use crate::FONT_BYTES;

fn face() -> &'static Face<'static> {
    static FACE: OnceLock<Face<'static>> = OnceLock::new();
    FACE.get_or_init(|| Face::parse(FONT_BYTES, 0).expect("bundled math font must parse"))
}

pub fn units_per_em() -> f32 {
    face().units_per_em() as f32
}

// Used only by tests; kept as a small reusable font-capability probe.
#[allow(dead_code)]
pub fn has_math_table() -> bool {
    face().tables().math.is_some()
}

/// Look up the glyph ID for a Unicode codepoint via the font's cmap.
/// Returns `None` if the codepoint is not present in the font.
pub fn glyph_id(ch: char) -> Option<GlyphId> {
    face().glyph_index(ch)
}

/// Map an ASCII/Greek letter (or digit) to its Unicode math-alphanumeric
/// codepoint for the given LaTeX font face (`\mathbb`, `\mathcal`, `\mathfrak`,
/// `\mathbf`, `\mathsf`, `\mathtt`, …). Ported from pulldown-latex's
/// `Font::map_char` so our SVG path matches its MathML path. Characters with no
/// styled form (and the `UpRight`/`None` default) map to themselves; the caller
/// must still check the resulting codepoint has a glyph and fall back if not.
pub fn map_variant(font: pulldown_latex::event::Font, c: char) -> char {
    use pulldown_latex::event::Font;
    char::from_u32(match (font, c) {
        // Bold Script
        (Font::BoldScript, 'A'..='Z') => c as u32 + 0x1D48F,
        (Font::BoldScript, 'a'..='z') => c as u32 + 0x1D489,
        // Bold Italic
        (Font::BoldItalic, 'A'..='Z') => c as u32 + 0x1D427,
        (Font::BoldItalic, 'a'..='z') => c as u32 + 0x1D421,
        (Font::BoldItalic, '\u{0391}'..='\u{03A1}' | '\u{03A3}'..='\u{03A9}') => c as u32 + 0x1D38B,
        (Font::BoldItalic, '\u{03B1}'..='\u{03C9}') => c as u32 + 0x1D385,
        // Bold
        (Font::Bold, 'A'..='Z') => c as u32 + 0x1D3BF,
        (Font::Bold, 'a'..='z') => c as u32 + 0x1D3B9,
        (Font::Bold, '\u{0391}'..='\u{03A1}' | '\u{03A3}'..='\u{03A9}') => c as u32 + 0x1D317,
        (Font::Bold, '\u{03B1}'..='\u{03C9}') => c as u32 + 0x1D311,
        (Font::Bold, '0'..='9') => c as u32 + 0x1D79E,
        // Fraktur
        (Font::Fraktur, 'A' | 'B' | 'D'..='G' | 'J'..='Q' | 'S'..='Y') => c as u32 + 0x1D4C3,
        (Font::Fraktur, 'C') => c as u32 + 0x20EA,
        (Font::Fraktur, 'H' | 'I') => c as u32 + 0x20C4,
        (Font::Fraktur, 'R') => c as u32 + 0x20CA,
        (Font::Fraktur, 'Z') => c as u32 + 0x20CE,
        (Font::Fraktur, 'a'..='z') => c as u32 + 0x1D4BD,
        // Script (calligraphic) — \mathcal
        (Font::Script, 'A' | 'C' | 'D' | 'G' | 'J' | 'K' | 'N'..='Q' | 'S'..='Z') => c as u32 + 0x1D45B,
        (Font::Script, 'B') => c as u32 + 0x20EA,
        (Font::Script, 'E' | 'F') => c as u32 + 0x20EB,
        (Font::Script, 'H') => c as u32 + 0x20C3,
        (Font::Script, 'I') => c as u32 + 0x20C7,
        (Font::Script, 'L') => c as u32 + 0x20C6,
        (Font::Script, 'M') => c as u32 + 0x20E6,
        (Font::Script, 'R') => c as u32 + 0x20C9,
        (Font::Script, 'a'..='d' | 'f' | 'h'..='n' | 'p'..='z') => c as u32 + 0x1D455,
        (Font::Script, 'e') => c as u32 + 0x20CA,
        (Font::Script, 'g') => c as u32 + 0x20A3,
        (Font::Script, 'o') => c as u32 + 0x20C5,
        // Monospace
        (Font::Monospace, 'A'..='Z') => c as u32 + 0x1D62F,
        (Font::Monospace, 'a'..='z') => c as u32 + 0x1D629,
        (Font::Monospace, '0'..='9') => c as u32 + 0x1D7C6,
        // Sans Serif
        (Font::SansSerif, 'A'..='Z') => c as u32 + 0x1D55F,
        (Font::SansSerif, 'a'..='z') => c as u32 + 0x1D559,
        (Font::SansSerif, '0'..='9') => c as u32 + 0x1D7B2,
        // Double Struck — \mathbb
        (Font::DoubleStruck, 'A' | 'B' | 'D'..='G' | 'I'..='M' | 'O' | 'S'..='Y') => c as u32 + 0x1D4F7,
        (Font::DoubleStruck, 'C') => c as u32 + 0x20BF,
        (Font::DoubleStruck, 'H') => c as u32 + 0x20C5,
        (Font::DoubleStruck, 'N') => c as u32 + 0x20C7,
        (Font::DoubleStruck, 'P' | 'Q') => c as u32 + 0x20C9,
        (Font::DoubleStruck, 'R') => c as u32 + 0x20CB,
        (Font::DoubleStruck, 'Z') => c as u32 + 0x20CA,
        (Font::DoubleStruck, 'a'..='z') => c as u32 + 0x1D4F1,
        (Font::DoubleStruck, '0'..='9') => c as u32 + 0x1D7A8,
        // Italic
        (Font::Italic, 'A'..='Z') => c as u32 + 0x1D3F3,
        (Font::Italic, 'a'..='g' | 'i'..='z') => c as u32 + 0x1D3ED,
        (Font::Italic, 'h') => c as u32 + 0x20A6,
        (Font::Italic, '\u{0391}'..='\u{03A1}' | '\u{03A3}'..='\u{03A9}') => c as u32 + 0x1D351,
        (Font::Italic, '\u{03B1}'..='\u{03C9}') => c as u32 + 0x1D34B,
        // Bold Fraktur
        (Font::BoldFraktur, 'A'..='Z') => c as u32 + 0x1D52B,
        (Font::BoldFraktur, 'a'..='z') => c as u32 + 0x1D525,
        // Sans Serif Italic
        (Font::SansSerifItalic, 'A'..='Z') => c as u32 + 0x1D5D7,
        (Font::SansSerifItalic, 'a'..='z') => c as u32 + 0x1D5C1,
        // Bold Sans Serif
        (Font::BoldSansSerif, 'A'..='Z') => c as u32 + 0x1D593,
        (Font::BoldSansSerif, 'a'..='z') => c as u32 + 0x1D58D,
        (Font::BoldSansSerif, '0'..='9') => c as u32 + 0x1D7BC,
        // Sans Serif Bold Italic
        (Font::SansSerifBoldItalic, 'A'..='Z') => c as u32 + 0x1D5FB,
        (Font::SansSerifBoldItalic, 'a'..='z') => c as u32 + 0x1D5F5,
        // UpRight + anything unmapped → itself.
        (_, _) => c as u32,
    })
    .unwrap_or(c)
}

/// Map the character pulldown-latex emits for an accent (`^`, `→`, `~`, `‾`/`¯`,
/// …) to the font's **combining** accent glyph (U+03xx / U+20D7). The combining
/// glyphs are the correctly-proportioned over-accent forms (zero advance,
/// designed to overlay), unlike the spacing chars pulldown surfaces. Returns
/// `None` if the character isn't a recognized accent.
pub fn accent_glyph(ch: char) -> Option<GlyphId> {
    let combining = match ch {
        '^' | '\u{0302}' | 'ˆ' => '\u{0302}',  // \hat
        '~' | '\u{0303}' | '˜' => '\u{0303}',  // \tilde
        '' | '¯' | '\u{0304}' => '\u{0304}', // \bar (¯ = U+00AF)
        '' | '\u{20D7}' => '\u{20D7}',        // \vec
        '˙' | '\u{0307}' => '\u{0307}',        // \dot
        '¨' | '\u{0308}' => '\u{0308}',        // \ddot
        'ˇ' | '\u{030C}' => '\u{030C}',        // \check
        '˘' | '\u{0306}' => '\u{0306}',        // \breve
        '´' | '\u{0301}' => '\u{0301}',        // \acute
        '`' | '\u{0300}' => '\u{0300}',        // \grave
        _ => return None,
    };
    face().glyph_index(combining)
}

/// Pixel-space metrics for a glyph at a given font size.
/// All values are in SVG-down y space; `height` is above baseline, `depth` below.
#[derive(Debug, Clone, Copy)]
pub struct GlyphMetrics {
    pub advance: f32,
    pub height: f32,
    pub depth: f32,
}

/// Horizontal extent (`x_min`, `x_max`) of a glyph's outline in pixels at a
/// given font size. Useful for glyphs whose advance is zero (combining accents),
/// where the visible width comes from the bounding box, not the advance.
/// Returns `(0.0, 0.0)` if the glyph has no outline.
pub fn glyph_x_bounds(id: GlyphId, font_size: f32) -> (f32, f32) {
    let face = face();
    let scale = font_size / face.units_per_em() as f32;
    match face.glyph_bounding_box(id) {
        Some(b) => (b.x_min as f32 * scale, b.x_max as f32 * scale),
        None => (0.0, 0.0),
    }
}

pub fn glyph_metrics(id: GlyphId, font_size: f32) -> GlyphMetrics {
    let face = face();
    let upem = face.units_per_em() as f32;
    let scale = font_size / upem;

    let advance = face.glyph_hor_advance(id).unwrap_or(0) as f32 * scale;
    let bbox = face.glyph_bounding_box(id);
    let (height, depth) = match bbox {
        Some(b) => (b.y_max as f32 * scale, (-(b.y_min as f32)) * scale),
        None => (0.0, 0.0),
    };
    GlyphMetrics {
        advance,
        height,
        depth,
    }
}

struct PathBuilder(String);

impl ttf_parser::OutlineBuilder for PathBuilder {
    fn move_to(&mut self, x: f32, y: f32) {
        let _ = write!(self.0, "M{} {} ", x, y);
    }
    fn line_to(&mut self, x: f32, y: f32) {
        let _ = write!(self.0, "L{} {} ", x, y);
    }
    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
        let _ = write!(self.0, "Q{} {} {} {} ", x1, y1, x, y);
    }
    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
        let _ = write!(self.0, "C{} {} {} {} {} {} ", x1, y1, x2, y2, x, y);
    }
    fn close(&mut self) {
        self.0.push_str("Z ");
    }
}

/// OpenType MATH table constant selector.
///
/// All variants resolve to font design units (via `MathValue.value: i16`) and are
/// scaled to pixels by `math_constant()`, except `RadicalDegreeBottomRaisePercent`
/// which is returned by ttf-parser as `i16` percent and converted to a 0..1 ratio.
// Not all constants are consumed yet; the unused ones back LaTeX features
// scheduled for later tiers (limits, stretch stacks). Kept for completeness.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub enum MathConstant {
    AxisHeight,
    FractionNumeratorShiftUp,
    FractionDenominatorShiftDown,
    FractionRuleThickness,
    FractionNumDisplayStyleShiftUp,
    FractionDenomDisplayStyleShiftDown,
    FractionNumeratorGapMin,
    FractionNumDisplayStyleGapMin,
    FractionDenominatorGapMin,
    FractionDenomDisplayStyleGapMin,
    SubscriptShiftDown,
    SubscriptTopMax,
    SubscriptBaselineDropMin,
    SuperscriptShiftUp,
    SuperscriptShiftUpCramped,
    SuperscriptBottomMin,
    SuperscriptBaselineDropMax,
    SubSuperscriptGapMin,
    SuperscriptBottomMaxWithSubscript,
    RadicalRuleThickness,
    RadicalVerticalGap,
    RadicalDisplayStyleVerticalGap,
    RadicalKernBeforeDegree,
    RadicalKernAfterDegree,
    /// Returned as a unitless ratio (e.g. 0.6 for 60%), not pixels.
    RadicalDegreeBottomRaisePercent,
    UpperLimitGapMin,
    UpperLimitBaselineRiseMin,
    LowerLimitGapMin,
    LowerLimitBaselineDropMin,
    AccentBaseHeight,
}

/// Read a MATH table constant, scaled to pixels at the given font size.
///
/// Returns `0.0` if the font lacks a MATH table (should not happen with the
/// bundled Latin Modern Math). `RadicalDegreeBottomRaisePercent` returns a
/// 0..1 ratio instead of pixels (font_size is ignored for that variant).
pub fn math_constant(c: MathConstant, font_size: f32) -> f32 {
    let face = face();
    let scale = font_size / face.units_per_em() as f32;
    let Some(math) = face.tables().math else {
        return 0.0;
    };
    let Some(consts) = math.constants else {
        return 0.0;
    };
    use MathConstant::*;
    let value: i16 = match c {
        AxisHeight => consts.axis_height().value,
        FractionNumeratorShiftUp => consts.fraction_numerator_shift_up().value,
        FractionDenominatorShiftDown => consts.fraction_denominator_shift_down().value,
        FractionRuleThickness => consts.fraction_rule_thickness().value,
        FractionNumDisplayStyleShiftUp => consts.fraction_numerator_display_style_shift_up().value,
        FractionDenomDisplayStyleShiftDown => {
            consts.fraction_denominator_display_style_shift_down().value
        }
        FractionNumeratorGapMin => consts.fraction_numerator_gap_min().value,
        FractionNumDisplayStyleGapMin => consts.fraction_num_display_style_gap_min().value,
        FractionDenominatorGapMin => consts.fraction_denominator_gap_min().value,
        FractionDenomDisplayStyleGapMin => consts.fraction_denom_display_style_gap_min().value,
        SubscriptShiftDown => consts.subscript_shift_down().value,
        SubscriptTopMax => consts.subscript_top_max().value,
        SubscriptBaselineDropMin => consts.subscript_baseline_drop_min().value,
        SuperscriptShiftUp => consts.superscript_shift_up().value,
        SuperscriptShiftUpCramped => consts.superscript_shift_up_cramped().value,
        SuperscriptBottomMin => consts.superscript_bottom_min().value,
        SuperscriptBaselineDropMax => consts.superscript_baseline_drop_max().value,
        SubSuperscriptGapMin => consts.sub_superscript_gap_min().value,
        SuperscriptBottomMaxWithSubscript => consts.superscript_bottom_max_with_subscript().value,
        RadicalRuleThickness => consts.radical_rule_thickness().value,
        RadicalVerticalGap => consts.radical_vertical_gap().value,
        RadicalDisplayStyleVerticalGap => consts.radical_display_style_vertical_gap().value,
        RadicalKernBeforeDegree => consts.radical_kern_before_degree().value,
        RadicalKernAfterDegree => consts.radical_kern_after_degree().value,
        RadicalDegreeBottomRaisePercent => {
            return consts.radical_degree_bottom_raise_percent() as f32 / 100.0;
        }
        UpperLimitGapMin => consts.upper_limit_gap_min().value,
        UpperLimitBaselineRiseMin => consts.upper_limit_baseline_rise_min().value,
        LowerLimitGapMin => consts.lower_limit_gap_min().value,
        LowerLimitBaselineDropMin => consts.lower_limit_baseline_drop_min().value,
        AccentBaseHeight => consts.accent_base_height().value,
    };
    value as f32 * scale
}

/// Find the smallest vertical glyph variant whose advance measurement is
/// `>= target_design_units`. If no variant reaches that size, return the
/// **largest available** variant instead — so callers asking for huge
/// delimiters (e.g. around a triple-stacked fraction) still get the biggest
/// glyph the font can provide, rather than silently falling back to the base
/// glyph via `unwrap_or(base)`.
///
/// Returns `None` only when the glyph has no `MathVariants` construction entry
/// (i.e. it's not a stretchy glyph in this font).
///
/// v0.1 ignores `GlyphAssembly` (extensible glyphs built from parts) — that is
/// deferred to v0.2.
pub fn math_variant_vertical(base: GlyphId, target_design_units: f32) -> Option<(GlyphId, f32)> {
    let math = face().tables().math?;
    let variants = math.variants?;
    let construction = variants.vertical_constructions.get(base)?;
    let mut largest: Option<(GlyphId, f32)> = None;
    for v in construction.variants {
        let adv = v.advance_measurement as f32;
        if adv >= target_design_units {
            return Some((v.variant_glyph, adv));
        }
        match largest {
            Some((_, h)) if h >= adv => {}
            _ => largest = Some((v.variant_glyph, adv)),
        }
    }
    largest
}

/// Emit the glyph's outline as an SVG path data string in font design units (y-up).
/// Caller MUST apply `matrix(s 0 0 -s ox oy)` transform where `s = font_size / units_per_em`
/// to convert to SVG (y-down) pixel space.
/// Returns empty string for blank glyphs.
pub fn outline_path(id: GlyphId) -> String {
    let mut b = PathBuilder(String::new());
    let _ = face().outline_glyph(id, &mut b);
    b.0
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- font_smoke.rs ---
    #[test]
    fn face_loads_and_has_math_table() {
        let upem = units_per_em();
        assert!(upem > 0.0, "units_per_em should be > 0, got {}", upem);
        assert!(has_math_table(), "bundled font must have MATH table");
    }

    // --- font_glyph.rs ---
    #[test]
    fn maps_ascii_letter_to_glyph_id() {
        let id = glyph_id('E').expect("E must exist in bundled math font");
        assert!(id.0 > 0);
    }

    #[test]
    fn maps_greek_alpha() {
        let id = glyph_id('α').expect("α must exist");
        assert!(id.0 > 0);
    }

    #[test]
    fn returns_none_for_unmapped_codepoint() {
        assert!(glyph_id('\u{E000}').is_none());
    }

    // --- font_metrics.rs ---
    #[test]
    fn metrics_for_capital_e() {
        let id = glyph_id('E').unwrap();
        let m = glyph_metrics(id, 16.0);
        assert!(m.advance > 0.0);
        assert!(m.height > 0.0);
        assert!(m.depth >= 0.0);
        assert!(m.height > m.depth);
    }

    #[test]
    fn metrics_scale_linearly_with_size() {
        let id = glyph_id('E').unwrap();
        let m1 = glyph_metrics(id, 10.0);
        let m2 = glyph_metrics(id, 20.0);
        let ratio = m2.advance / m1.advance;
        assert!(
            (ratio - 2.0).abs() < 1e-3,
            "expected 2.0 ratio, got {}",
            ratio
        );
    }

    // --- font_math_table.rs ---
    #[test]
    fn reads_axis_height() {
        let h = math_constant(MathConstant::AxisHeight, 16.0);
        assert!(
            h > 0.0 && h < 16.0,
            "AxisHeight should be small positive px, got {}",
            h
        );
    }

    #[test]
    fn reads_fraction_rule_thickness() {
        let t = math_constant(MathConstant::FractionRuleThickness, 16.0);
        assert!(
            t > 0.0 && t < 2.0,
            "FractionRuleThickness should be ~1px, got {}",
            t
        );
    }

    // --- font_math_variant.rs ---
    #[test]
    fn integral_has_bigger_variant() {
        let id = glyph_id('').unwrap();
        let (variant, advance) = math_variant_vertical(id, 1500.0)
            .expect("integral must have a bigger vertical variant");
        assert!(
            variant != id,
            "should return a different glyph for bigger size"
        );
        assert!(advance >= 1500.0);
    }

    #[test]
    fn returns_none_for_atom_without_variants() {
        let id = glyph_id('E').unwrap();
        assert!(math_variant_vertical(id, 50.0).is_none());
    }

    #[test]
    fn returns_largest_when_target_exceeds_all_variants() {
        let id = glyph_id('').unwrap();
        let (variant, h) = math_variant_vertical(id, 1e9)
            .expect("should fall back to largest variant, not None");
        assert!(
            variant != id,
            "should return a non-base variant; got base glyph"
        );
        assert!(
            h > 1500.0,
            "largest variant should be substantially bigger than base; got {}",
            h,
        );
    }

    // --- font_outline.rs ---
    #[test]
    fn outlines_capital_e_to_path_string() {
        let id = glyph_id('E').unwrap();
        let path = outline_path(id);
        assert!(
            path.contains('M'),
            "path should contain at least one Move: {}",
            path
        );
        assert!(
            path.contains('L') || path.contains('C') || path.contains('Q'),
            "path should contain at least one line/curve segment: {}",
            path
        );
        assert!(
            path.ends_with('Z') || path.contains("Z "),
            "path should be closed: {}",
            path
        );
    }

    #[test]
    fn outline_uses_design_units_no_scaling() {
        let id = glyph_id('E').unwrap();
        let path = outline_path(id);
        let any_large = path
            .split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
            .filter_map(|s| s.parse::<f32>().ok())
            .any(|n| n.abs() > 50.0);
        assert!(any_large, "expected design-unit magnitudes, got: {}", path);
    }
}