rustyfi-pdf 0.1.3

PDF output backend for SATySFi (pdf-writer based, base-14 fonts)
Documentation
//! OpenType MATH-table metrics (constants + scripts + axis + italic-kern):
//! `TtfFontStore` reads a real MATH font's `MathConstants`/italic-correction
//! table, and `Base14Metrics` overrides none of the new `FontMetrics`
//! methods (the base-14 regression floor depends on this).
//!
//! Headline: laying out `${x^2}` under a real MATH font produces a
//! superscript shift that differs from the fixed `12pt * 0.5 = 6.0pt`
//! base-14 heuristic, and equals an independently recomputed clamped shift
//! (`math.ml:524-533`'s `superscript_baseline_height`) — the wired-up
//! pipeline, not just the raw accessor, does the real thing.
//!
//! Font discovery mirrors `tests/math_font.rs`: fontconfig first, then a
//! handful of common distro/nix paths, then a graceful skip.

use std::path::{Path, PathBuf};
use std::process::Command;

use rustyfi_backend::{FontKey, FontMetrics, HorzBox, Length, PureHorzBox};
use rustyfi_lang::value::Value;
use rustyfi_lang::{elaborate, eval, primitives, typecheck, CompileError};
use rustyfi_pdf::{Base14Metrics, TtfFontStore};

/// Locate a real MATH-capable OpenType font, preferring the bundled Latin
/// Modern Math (CFF), same discovery order/guard as `tests/math_font.rs`.
fn find_math_font() -> Option<PathBuf> {
    // Every assertion in this file recomputes its expected value
    // from whatever font is actually loaded, so it is font-independent.
    let bundled_lmmath = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../lib-rustyfi/dist/fonts/latinmodern-math.otf");
    if bundled_lmmath.is_file() {
        return Some(bundled_lmmath);
    }
    let bundled_dejavu = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../lib-rustyfi/dist/fonts/DejaVuMathTeXGyre.ttf");
    if bundled_dejavu.is_file() {
        return Some(bundled_dejavu);
    }

    for family in ["DejaVu Math TeX Gyre", "Noto Sans Math"] {
        if let Ok(output) = Command::new("fc-match")
            .args(["--format=%{file}", family])
            .output()
        {
            if output.status.success() {
                let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
                if !path.is_empty()
                    && Path::new(&path).is_file()
                    && (path.contains("Math") || path.contains("math"))
                {
                    return Some(PathBuf::from(path));
                }
            }
        }
    }

    for candidate in [
        "/usr/share/texmf/fonts/opentype/public/dejavu-otf/DejaVuMathTeXGyre.ttf",
        "/usr/share/fonts/opentype/dejavu-math-tex-gyre/DejaVuMathTeXGyre.ttf",
        "/usr/share/fonts/truetype/tex-gyre/texgyredejavu-math.otf",
        "/usr/share/fonts/noto/NotoSansMath-Regular.ttf",
        "/usr/share/fonts/truetype/noto/NotoSansMath-Regular.ttf",
        "/usr/share/fonts/opentype/noto/NotoSansMath-Regular.ttf",
        "/usr/share/fonts/OTF/NotoSansMath-Regular.otf",
        "/run/current-system/sw/share/fonts/truetype/NotoSansMath-Regular.ttf",
        "/run/current-system/sw/share/X11/fonts/NotoSansMath-Regular.ttf",
    ] {
        if Path::new(candidate).is_file() {
            return Some(PathBuf::from(candidate));
        }
    }

    None
}

macro_rules! need_math_font {
    () => {
        match find_math_font() {
            Some(path) => path,
            None => {
                eprintln!(
                    "skipping: no math-capable OpenType font found on this system \
                     (tried `fc-match` for \"DejaVu Math TeX Gyre\"/\"Noto Sans Math\" \
                     and common nix/distro paths)"
                );
                return;
            }
        }
    };
}

// 1. Raw MathConstants/italic-correction accessors.

#[test]
fn math_font_exposes_plausible_math_constants() {
    let path = need_math_font!();
    let store = TtfFontStore::load(&path, None, None).expect("load math font");

    let mc = store
        .math_constants(FontKey(0))
        .expect("a real MATH font must expose MathConstants");

    for (name, v) in [
        ("axis_height", mc.axis_height),
        ("superscript_shift_up", mc.superscript_shift_up),
        ("fraction_rule_thickness", mc.fraction_rule_thickness),
    ] {
        assert!(
            v > 0.0 && v < 1.0,
            "{name} = {v} should be a nonzero (0,1) ratio of the font size"
        );
    }
    assert!(
        mc.script_scale_down > 0.0 && mc.script_scale_down < 1.0,
        "script_scale_down = {} should be a nonzero (0,1) ratio",
        mc.script_scale_down
    );
}

#[test]
fn base14_overrides_none_of_the_math_methods() {
    let base14 = Base14Metrics;
    assert!(
        base14.math_constants(FontKey(0)).is_none(),
        "Base14Metrics must inherit the trait's defaulted None (§B1 base-14 \
         regression floor)"
    );
    assert!(base14
        .italic_correction(FontKey(0), 'f', Length::pt(12.0))
        .is_none());
    assert!(base14
        .math_kern(
            FontKey(0),
            'f',
            Length::pt(12.0),
            rustyfi_backend::MathCorner::TopRight,
            Length::ZERO,
        )
        .is_none());
}

#[test]
fn math_font_has_positive_italic_correction_for_f() {
    let path = need_math_font!();
    let store = TtfFontStore::load(&path, None, None).expect("load math font");

    // 'f' is the textbook italic-correction example (its cursive tail
    // leans into whatever follows it) — both DejaVu Math TeX Gyre and Noto
    // Sans Math give it a positive, nonzero correction.
    let ic = store
        .italic_correction(FontKey(0), 'f', Length::pt(12.0))
        .expect("MATH font should have an italic correction for 'f'");
    assert!(ic.0 > 0.0, "expected a positive italic correction, got {ic:?}");
}

#[test]
fn math_kern_does_not_panic_and_degrades_gracefully() {
    // Neither DejaVu Math TeX Gyre nor Noto Sans Math populate per-glyph
    // MathKernInfo (verified by direct ttf-parser inspection at
    // implementation time), so this only proves the accessor chain
    // (`face.tables().math?.glyph_info?.kern_infos?.get(gid)?`) doesn't
    // panic and degrades to `None` — the exact "missing kern data ->
    // zero-correction, not error" contract.
    let path = need_math_font!();
    let store = TtfFontStore::load(&path, None, None).expect("load math font");
    for corner in [
        rustyfi_backend::MathCorner::TopRight,
        rustyfi_backend::MathCorner::TopLeft,
        rustyfi_backend::MathCorner::BottomRight,
        rustyfi_backend::MathCorner::BottomLeft,
    ] {
        let _ = store.math_kern(FontKey(0), 'f', Length::pt(12.0), corner, Length::ZERO);
    }
}

// 2. Headline proof: the real pipeline (parse -> elaborate -> typecheck ->
//    eval -> layout_math_atom's Sup arm) produces a MATH-font superscript
//    shift that differs from base-14's flat heuristic and matches an
//    independently recomputed clamp.

fn run_math(src: &str, metrics: &dyn FontMetrics) -> Result<Value, CompileError> {
    let file = rustyfi_syntax::parse_file(src)?;
    let env = primitives::base_env();
    let store = rustyfi_lang::symbol::SymbolStore::new();
    let scope = elaborate::Scope::new(&store, env.names());
    let program = elaborate::elaborate_program(&file, &scope)?;
    typecheck::typecheck(&program)?;
    let mut interp = eval::Interp::new(metrics);
    Ok(interp.eval(&env, &rustyfi_lang::ast::debrand(&program.body, &store))?)
}

fn with_ctx(body: &str) -> String {
    format!(
        "let-inline ctx \\dummy m = inline-nil\n\
         in\n\
         let ctx = get-initial-context 200pt (command \\dummy) in\n\
         {body}"
    )
}

/// `sup_dy` plus the two ink extents `sup_shift_clamped` clamps against:
/// the base's height and the script's depth. Reading them back off the
/// laid-out glyphs is deliberate — they are per-GLYPH ink boxes (upstream's
/// `FontFormat.get_math_glyph_metrics`, fontFormat.ml:2257-2264), not the
/// font-wide ascender/descender, and re-deriving them here would just pin a
/// second copy of that convention instead of the clamp under test.
fn sup_dy_and_extents(v: Value) -> (Length, Length, Length) {
    match v {
        Value::InlineBoxes(boxes) => {
            assert_eq!(boxes.len(), 1, "expected exactly one box, got {boxes:?}");
            match boxes.into_iter().next().unwrap() {
                HorzBox::Pure(PureHorzBox::Math { glyphs, .. }) => {
                    assert_eq!(
                        glyphs.len(),
                        2,
                        "expected 2 glyphs (base 'x', script '2'), got {glyphs:?}"
                    );
                    assert_eq!(glyphs[1].text, "2");
                    (glyphs[1].dy, glyphs[0].height, glyphs[1].depth)
                }
                other => panic!("expected a PureHorzBox::Math, got {other:?}"),
            }
        }
        other => panic!("expected inline-boxes, got {other:?}"),
    }
}

/// Extract the superscript glyph's `dy` from `${x^2}`'s laid-out
/// `PureHorzBox::Math` — the SECOND glyph (`x` is the base, `2` the raised
/// script; the faithful `layout_math_atom::Sup` arm this exercises never
/// interleaves them differently for a single-char base/script).
fn sup_dy(v: Value) -> Length {
    match v {
        Value::InlineBoxes(boxes) => {
            assert_eq!(boxes.len(), 1, "expected exactly one box, got {boxes:?}");
            match boxes.into_iter().next().unwrap() {
                HorzBox::Pure(PureHorzBox::Math { glyphs, .. }) => {
                    assert_eq!(
                        glyphs.len(),
                        2,
                        "expected 2 glyphs (base 'x', script '2'), got {glyphs:?}"
                    );
                    assert_eq!(glyphs[1].text, "2");
                    glyphs[1].dy
                }
                other => panic!("expected a PureHorzBox::Math, got {other:?}"),
            }
        }
        other => panic!("expected inline-boxes, got {other:?}"),
    }
}

#[test]
fn headline_math_font_superscript_shift_differs_from_flat_heuristic() {
    let path = need_math_font!();
    let store = TtfFontStore::load(&path, None, None).expect("load math font");
    let size = Length::pt(12.0);

    // Base-14: `layout_math_atom`'s `MathC` falls back to the fixed
    // `SUP_SHIFT = 0.5` constant this port used before MATH-table support
    // landed -> exactly
    // 12pt * 0.5 = 6.0pt, unclamped.
    let base14_src = with_ctx("embed-math ctx ${x^2}");
    let v = run_math(&base14_src, &Base14Metrics)
        .expect("${x^2} should compile and evaluate under base-14");
    let base14_dy = sup_dy(v);
    assert_eq!(
        base14_dy,
        Length::pt(6.0),
        "base-14 (no MATH table) should keep the flat 12pt*SUP_SHIFT heuristic"
    );

    // Real MATH font: independently recompute math.ml:524-533's clamped
    // `superscript_baseline_height` by hand, from the SAME quantities
    // `MathC::sup_shift_clamped` reads — the font's own `MathConstants` plus
    // the base's and script's per-GLYPH ink extents (upstream's
    // `get_math_glyph_metrics`, fontFormat.ml:2257-2264: `hgt = ymax`,
    // `dpt = ymin`, each truncated towards the baseline). NOT the font-wide
    // ascender/descender, which is what `push_char_glyph` used to hand the
    // clamp and which put every script 1.7-2.1pt off
    // (`layout-tests/probes/math_script_drop.saty`).
    let mc = store
        .math_constants(FontKey(0))
        .expect("MATH font should expose MathConstants");
    let script_size = size * mc.script_scale_down;

    let math_src = with_ctx("embed-math ctx ${x^2}");
    let v = run_math(&math_src, &store)
        .expect("${x^2} should compile and evaluate under a real MATH font");
    let (math_dy, h_base, d_sup) = sup_dy_and_extents(v);
    let cand1 = size * mc.superscript_shift_up;
    let cand2 = h_base - size * mc.superscript_baseline_drop_max;
    let cand3 = size * mc.superscript_bottom_min + d_sup;
    let expected = cand1.max(cand2).max(cand3);

    assert_ne!(
        math_dy, base14_dy,
        "a real MATH font's clamped superscript shift ({math_dy:?}) should differ \
         from the flat 6.0pt heuristic"
    );
    assert_eq!(
        math_dy, expected,
        "the wired-up pipeline's shift should equal the independently \
         recomputed math.ml:524-533 clamp"
    );

    assert!(
        script_size < size,
        "script_scale_down should shrink the script size below font_size"
    );
    assert_eq!(script_size, size * mc.script_scale_down);
}