From 21603e6c810bd3c7fce90281f9f2e286ba9af7bc Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:04:25 +0000 Subject: [PATCH] TEST.1: correct font shaping and embedding oracles Co-authored-by: Codex --- crates/pdf/tests/embedded_system_fonts.rs | 111 ++++++++++++++++++++++ crates/text/src/tests_bidi.rs | 70 +++++++++++--- 2 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 crates/pdf/tests/embedded_system_fonts.rs diff --git a/crates/pdf/tests/embedded_system_fonts.rs b/crates/pdf/tests/embedded_system_fonts.rs new file mode 100644 index 00000000..ea22a64f --- /dev/null +++ b/crates/pdf/tests/embedded_system_fonts.rs @@ -0,0 +1,111 @@ +//! PDF import names type by the installed fonts in a new session (#130). One test in its own +//! process, so the process-wide font database starts fresh, as it does when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::fmt::Write; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::raw::TableProvider; +use skrifa::string::StringId; +use skrifa::{FontRef, MetadataProvider}; +use vectorcraft_doc::NodeKind; +use vectorcraft_pdf::{ImportOptions, import_with_report}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::{FontDb, system_font_dirs}; + +#[test] +fn a_fresh_session_resolves_pdf_fonts_to_installed_families() { + let bundled = FontDb::with_font_dirs(vec![]).families(); + let probe = FontDb::with_font_dirs(system_font_dirs()); + probe.load_system_fonts(); + let installed = probe.families(); + // The separate probe supplies the PDF font; the global database stays fresh until import. + let Some((family, face, postscript_name)) = installed + .iter() + .filter(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ')) + .find_map(|family| { + let face = probe.face(family, "Regular")?; + // FontFile2 embeds a standalone TrueType program, not a collection or a named + // variable instance. WinAnsi's H/i must map to actual glyphs in that program. + if !face.family.eq_ignore_ascii_case(family) + || face.face_index() != 0 + || !face.file_data().starts_with(&[0, 1, 0, 0]) + || !face.variations().is_empty() + || face.italic + || !face.embeddable() + || ['H', 'i'].iter().any(|&c| face.glyph_for(c) == 0) + { + return None; + } + let font = FontRef::from_index(face.file_data(), face.face_index()).ok()?; + font.head().ok()?; + font.glyf().ok()?; + let base_font = font.localized_strings(StringId::POSTSCRIPT_NAME).english_or_first()?.to_string(); + (!base_font.is_empty() && base_font.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .then_some((family, face, base_font)) + }) + else { + eprintln!("no usable installed standalone TrueType face for this PDF fixture: nothing to check"); + return; + }; + let font = FontRef::from_index(face.file_data(), face.face_index()).unwrap(); + let head = font.head().unwrap(); + let scale = 1000.0 / face.units_per_em(); + let (ascent, descent) = face.vertical_metrics(); + let cap_height = font.metrics(Size::unscaled(), LocationRef::default()).cap_height.map(f64::from).unwrap_or(ascent); + let flags = 32 | u8::from(font.post().ok().is_some_and(|post| post.is_fixed_pitch() != 0)); + let descriptor_number = first_extra(1); + // pdf_with accepts string objects; ASCIIHex carries the runtime font bytes losslessly. + let mut encoded = String::with_capacity(face.file_data().len() * 2 + 1); + for byte in face.file_data() { + write!(encoded, "{byte:02X}").unwrap(); + } + encoded.push('>'); + let font_program = + format!("<< /Length {} /Length1 {} /Filter /ASCIIHexDecode >>\nstream\n{encoded}\nendstream", encoded.len(), face.file_data().len(),); + let widths = (b'H'..=b'i').map(|code| format!("{:.6}", face.advance(face.glyph_for(char::from(code))) * scale)).collect::>().join(" "); + let packed_family = family.replace(' ', ""); + // Keep the original packed-family boundary first: only this import sees a fresh global DB. + // Both names use the same embedded glyph program, widths and descriptor metrics. + for (case, base_font) in [("packed-family", packed_family.as_str()), ("PostScript", postscript_name.as_str())] { + let descriptor = format!( + "<< /Type /FontDescriptor /FontName /{base_font} /Flags {flags} /FontBBox [{:.6} {:.6} {:.6} {:.6}] \ + /ItalicAngle 0 /Ascent {:.6} /Descent {:.6} /CapHeight {:.6} /StemV 80 /FontFile2 {} 0 R >>", + f64::from(head.x_min()) * scale, + f64::from(head.y_min()) * scale, + f64::from(head.x_max()) * scale, + f64::from(head.y_max()) * scale, + ascent * scale, + -descent * scale, + cap_height * scale, + descriptor_number + 1, + ); + let resources = format!( + "/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{base_font} /Encoding /WinAnsiEncoding \ + /FirstChar 72 /LastChar 105 /Widths [{widths}] /FontDescriptor {descriptor_number} 0 R >> >>" + ); + let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") }; + let r = import_with_report(&pdf_with(&[page], &[&descriptor, &font_program], None), &ImportOptions::default()).unwrap(); + let mut families = vec![]; + let mut node_types = vec![]; + r.document.walk(|n| { + let kind = match &n.kind { + NodeKind::Text(_) => "Text", + NodeKind::Path { .. } => "Path", + _ => "Other", + }; + node_types.push((n.id.0, kind, n.name.clone())); + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + let diagnostics = format!( + "case={case}, selected_family={family:?}, base_font={base_font:?}, postscript_name={postscript_name:?}, warnings={:?}, node_types={node_types:?}", + r.warnings + ); + eprintln!("PDF system-font import: {diagnostics}"); + assert_eq!(families, std::slice::from_ref(family), "{diagnostics}"); + assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings); + } +} diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 998011cc..42634a3d 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -43,6 +43,26 @@ fn assert_same_shaped_glyphs(left: &TextLayout, right: &TextLayout) { } } +fn source_clusters(layout: &TextLayout) -> Vec<(usize, usize)> { + let mut clusters: Vec<_> = layout.glyphs.iter().map(|glyph| (glyph.byte, glyph.len)).collect(); + clusters.sort_unstable(); + clusters.dedup(); + clusters +} + +fn assert_source_coverage(text: &str, layout: &TextLayout) { + for glyph in &layout.glyphs { + let end = glyph.byte.checked_add(glyph.len).expect("source cluster end must fit usize"); + assert!(glyph.len > 0 && text.get(glyph.byte..end).is_some(), "invalid UTF-8 source cluster: {glyph:?}"); + } + let mut covered = 0; + for (byte, len) in source_clusters(layout) { + assert_eq!(byte, covered, "source clusters must cover {text:?} without gaps or overlaps"); + covered = byte + len; + } + assert_eq!(covered, text.len(), "source clusters must cover all of {text:?}"); +} + #[test] fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() { for s in ["שלום", "مرحبا"] { @@ -125,9 +145,15 @@ fn arabic_contextual_forms_and_lam_alef_use_the_shaper() { let st = arabic_style(&face); let pair = "لا"; let lam_alef = layout(db, &TextObject::point(Point::ZERO, pair, st.clone())); - let ligature = lam_alef.glyphs.iter().find(|g| g.byte == 0 && g.len == pair.len()); - assert!(ligature.is_some(), "lam-alef must retain one cluster spanning both source letters: {lam_alef:?}"); - assert_ne!(ligature.unwrap().gid, face.glyph_for('ل'), "lam-alef must use a shaped glyph"); + // Fonts may use a ligature or separate contextual lam/alef glyphs, with component marks. + assert_source_coverage(pair, &lam_alef); + for glyph in &lam_alef.glyphs { + assert!(glyph.rtl, "lam-alef must be RTL: {glyph:?}"); + assert_eq!(glyph.font_id, face.id()); + assert_ne!(glyph.gid, 0, "lam-alef must not use a missing glyph"); + let source = pair.get(glyph.byte..glyph.byte + glyph.len).expect("validated UTF-8 source cluster"); + assert!(source.chars().all(|c| glyph.gid != face.glyph_for(c)), "lam-alef must use contextual glyphs rather than nominal glyphs: {glyph:?}"); + } for s in ["سلام", "שלום سلام"] { let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone())); @@ -156,10 +182,20 @@ fn identical_style_arabic_run_splits_keep_joining_and_source_attribution() { let split = layout(db, &split); assert!(single.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "single run must exercise contextual beh forms"); assert_same_shaped_glyphs(&single, &split); - assert_eq!(split.glyphs.len(), 2, "beh-beh should remain two source clusters"); + let beh_len = "ب".len(); + assert_eq!(source_clusters(&split), [(0, beh_len), (beh_len, beh_len)], "beh-beh should remain two source clusters"); + for glyph in &single.glyphs { + assert_eq!(glyph.run, 0); + } + // Every component, including zero-advance dots, belongs to its original source run. for glyph in &split.glyphs { - let expected_run = usize::from(glyph.byte >= "ب".len()); - assert_eq!(glyph.run, expected_run, "source byte {} belongs to run {expected_run}", glyph.byte); + let expected_run = usize::from(glyph.byte >= beh_len); + assert_eq!( + (glyph.run, glyph.byte, glyph.len), + (expected_run, expected_run * beh_len, beh_len), + "source byte {} belongs to run {expected_run}", + glyph.byte + ); } } @@ -178,13 +214,25 @@ fn different_style_arabic_boundary_receives_joining_context() { TextRun { text: "ب".into(), style: CharStyle { size: style.size * 1.5, ..style }, inline: None }, ]; let split = layout(db, &split); - assert_eq!(joined.glyphs.len(), 2); - assert_eq!(split.glyphs.len(), 2); - for glyph in &joined.glyphs { - let actual = split.glyphs.iter().find(|other| other.byte == glyph.byte).expect("same source cluster"); + let beh_len = "ب".len(); + let expected_clusters = [(0, beh_len), (beh_len, beh_len)]; + assert_eq!(source_clusters(&joined), expected_clusters); + assert_eq!(source_clusters(&split), expected_clusters); + assert!(joined.glyphs.iter().all(|glyph| glyph.rtl)); + // Compare the full component sequence; a first match by byte can hide or reuse a dot. + assert_eq!(split.glyphs.len(), joined.glyphs.len(), "style boundary must retain every component glyph"); + for (glyph, actual) in joined.glyphs.iter().zip(&split.glyphs) { assert_eq!(actual.gid, glyph.gid, "joining form changed at style boundary for byte {}", glyph.byte); + assert_eq!(actual.byte, glyph.byte); assert_eq!(actual.len, glyph.len); - assert_eq!(actual.run, usize::from(glyph.byte >= "ب".len())); + assert_eq!(actual.font_id, glyph.font_id); + assert_eq!(actual.rtl, glyph.rtl); + assert_eq!(glyph.run, 0); + assert_eq!( + (actual.run, actual.byte, actual.len), + (usize::from(glyph.byte >= beh_len), glyph.byte, beh_len), + "every component must retain its source run and cluster" + ); } }