use std::path::{Path, PathBuf};
use std::process::Command;
use rustyfi_backend::{
FontKey, FontMetrics, HorzBox, Length, Page, PageGeometry, PlacedLine, PureHorzBox,
};
use rustyfi_lang::value::Value;
use rustyfi_lang::{elaborate, eval, primitives, typecheck, CompileError};
use rustyfi_pdf::{render_pdf_ttf, TtfFontStore};
const A_ITALIC: char = '\u{1D44E}';
const MINUS: char = '\u{2212}';
const DBL_R: char = '\u{211D}';
const DBL_D: char = '\u{1D53B}';
fn find_math_font() -> Option<PathBuf> {
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 ["Noto Sans Math", "DejaVu Math TeX Gyre"] {
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/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",
"/usr/share/fonts/noto-fonts/NotoSansMath-Regular.ttf",
"/usr/share/fonts/texgyre/texgyredejavu-math.otf",
"/usr/share/fonts/truetype/tex-gyre/texgyredejavu-math.otf",
"/usr/share/texmf/fonts/opentype/public/dejavu-otf/DejaVuMathTeXGyre.ttf",
"/usr/share/fonts/opentype/dejavu-math-tex-gyre/DejaVuMathTeXGyre.ttf",
"/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 \"Noto Sans Math\"/\"DejaVu Math TeX Gyre\" \
and common nix/distro paths)"
);
return;
}
}
};
}
#[test]
fn math_font_measures_styled_codepoints() {
let path = need_math_font!();
let store = TtfFontStore::load(&path, None, None).expect("load math font");
let size = Length::pt(12.0);
for c in [A_ITALIC, MINUS, DBL_R, DBL_D] {
assert!(
store.advance(FontKey(0), c, size).is_some(),
"expected {path:?} to have an advance for U+{:04X} ({c:?})",
c as u32
);
}
}
#[test]
fn math_font_has_cmap_glyphs() {
let path = need_math_font!();
let store = TtfFontStore::load(&path, None, None).expect("load math font");
let face = store.face(FontKey(0)).expect("parse face");
for c in [A_ITALIC, MINUS, DBL_R, DBL_D] {
assert!(
face.glyph_index(c).is_some(),
"expected {path:?}'s cmap to cover U+{:04X} ({c:?})",
c as u32
);
}
}
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}"
)
}
fn math_box(v: Value) -> (Length, FontKey, Vec<rustyfi_backend::MathGlyph>) {
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 { width, glyphs, .. }) => {
let font = glyphs
.first()
.map(|g| g.info.font)
.unwrap_or(FontKey(0));
(width, font, glyphs)
}
other => panic!("expected a PureHorzBox::Math, got {other:?}"),
}
}
other => panic!("expected inline-boxes, got {other:?}"),
}
}
#[test]
fn embed_math_emits_styled_codepoints_under_math_font() {
let path = need_math_font!();
let store = TtfFontStore::load(&path, None, None).expect("load math font");
let src = with_ctx("embed-math ctx ${a}");
let v = run_math(&src, &store).expect("${a} should compile and evaluate under a real font");
let (_, font, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1, "expected 1 glyph, got {glyphs:?}");
assert_eq!(
glyphs[0].text, A_ITALIC.to_string(),
"expected the Mathematical Italic Small A remap"
);
assert_eq!(font, FontKey(0), "expected the default math_font (FontKey(0))");
let src = with_ctx("embed-math ctx ${a-b}");
let v = run_math(&src, &store).expect("${a-b} should compile and evaluate under a real font");
let (_, _, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 3, "expected 3 glyphs (a, -, b), got {glyphs:?}");
assert_eq!(
glyphs[1].text,
MINUS.to_string(),
"expected the middle glyph to be U+2212 MINUS SIGN"
);
let src = with_ctx("embed-math ctx (math-char-class MathDoubleStruck ${D})");
let v = run_math(&src, &store)
.expect("math-char-class MathDoubleStruck ${D} should compile and evaluate");
let (_, _, glyphs) = math_box(v);
assert_eq!(glyphs.len(), 1, "expected 1 glyph, got {glyphs:?}");
assert_eq!(
glyphs[0].text,
DBL_D.to_string(),
"expected the Mathematical Double-Struck Capital D remap"
);
}
#[test]
fn styled_math_renders_through_cid_pipeline() {
let path = need_math_font!();
let store = TtfFontStore::load(&path, None, None).expect("load math font");
let geometry = PageGeometry::default();
let size = Length::pt(18.0);
let font = FontKey(0);
let advance = store
.advance(font, A_ITALIC, size)
.expect("math font should measure U+1D44E");
let ascender = store.ascender(font, size);
let descender = store.descender(font, size);
let glyph = rustyfi_backend::MathGlyph {
info: rustyfi_backend::HorzStringInfo {
font,
size,
rising: Length::ZERO,
color: rustyfi_backend::Color::Gray(0.0),
},
text: A_ITALIC.to_string(),
gid: None,
dx: Length::ZERO,
dy: Length::ZERO,
width: advance,
height: ascender,
depth: descender,
};
let line = PlacedLine {
x: geometry.text_origin.0,
baseline_y: geometry.text_origin.1 + ascender,
contents: vec![(
Length::ZERO,
PureHorzBox::Math {
width: advance,
height: ascender,
depth: descender,
glyphs: vec![glyph],
rules: vec![],
},
)],
};
let page = Page {
body_lines: usize::MAX, lines: vec![line] };
let pdf_bytes = render_pdf_ttf(&geometry, &[page], &store, &[]).expect("render");
assert!(
pdf_bytes.starts_with(b"%PDF-"),
"output should start with a PDF header"
);
let flavour = std::fs::read(&path).expect("read font file");
let is_cff = flavour.starts_with(b"OTTO");
let want: &[u8] = if is_cff { b"FontFile3" } else { b"FontFile2" };
assert!(
pdf_bytes.windows(want.len()).any(|w| w == want),
"expected the math font to be embedded as {} (the {} face at {}) — asserting \
embedding, not just size, so 'smaller' cannot be satisfied by dropping the font",
String::from_utf8_lossy(want),
if is_cff { "CFF/OTTO" } else { "glyf" },
path.display()
);
let font_len = std::fs::metadata(&path).expect("stat font file").len() as usize;
assert!(
pdf_bytes.len() < font_len,
"expected the subsetted PDF ({} bytes) to be smaller than the whole source math \
font file ({} bytes, {})",
pdf_bytes.len(),
font_len,
path.display()
);
}