nigig-org/crates/apps/pdf/pdf-graphics/tests/phase5_exit_criterion.rs
andodeki eacc86077e feat(pdf): complete Phase 5 font engine and text metrics
Phase 5 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md.

Step 5.1, real metrics (new pdf-graphics/src/sfnt.rs):
- Reads head, hhea, OS/2 and hmtx from embedded TrueType/OpenType programs,
  which the review asks for by name. sTypoAscender/Descender are preferred
  over hhea when non-zero, since subset fonts often zero them; sCapHeight
  and sxHeight are only read from OS/2 version 2 and later, because reading
  them from a v1 table returns whatever bytes follow it.
- Every read is bounds-checked. A table pointing outside the file, a
  truncated directory or a zero unitsPerEm yields None rather than a panic
  or a divide by zero. These are untrusted embedded programs.
- resolve_font() now reads FontFile/FontFile2/FontFile3 and falls back to
  the descriptors declared Ascent/Descent only when no program is embedded.
- renderer.rs looks up a per-font ascent instead of leaving ascent_em
  permanently None, which had left the fallback ratio always in effect.

Step 5.2, accumulated advances (new pdf-graphics/src/advance.rs):
- measure_advance() implements PDF 32000-1 9.4.4 properly:
  (w0/1000 * Tfs + Tc + Tw) * Th/100, with Tw restricted to single-byte
  code 32. Applying Tw to a two-byte code whose low byte is 32 is a classic
  composite-font bug and is now covered by a test.
- glyph_advances() gives per-glyph widths so a caret or a partial selection
  rectangle no longer assumes even spacing.
- GlyphAdvanceCache is keyed by font name and size as the review specifies,
  and carries a generation so it rebuilds when page data changes rather
  than every frame. Redefining a font drops its stale measurements.

Step 5.3, composite fonts:
- The old build_cid_widths truncated every CID to u8, so any glyph above
  255 silently took the default width. It is replaced by CidWidths keyed by
  the real CID, with the CMap resolved from a predefined name or an
  embedded stream.
- ResolvedFont::decode_text returns None for a composite font with no
  ToUnicode map instead of guessing Latin-1 and producing plausible
  nonsense.

Tests: new tests/phase5_exit_criterion.rs asserts the exit criterion.
Selection across a proportional and a monospaced run verifies the second
run starts at the first ones true end; per-glyph advances sum to the run
advance and H measures wider than l, which an even-spacing estimate would
not. Copy returns correct Unicode through ToUnicode including a non-ASCII
scalar, and through WinAnsi for simple fonts. Search finds both hits across
two lines with rectangles inside their own runs and the right vertical
order. Cache reuse is asserted through hit and miss counts.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (203 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (238 tests)
Both rustfmt and clippy -D warnings clean.
2026-07-27 16:51:18 +00:00

295 lines
10 KiB
Rust

//! Phase 5 exit criterion.
//!
//! > Select text across a line with mixed fonts. Verify selection rectangles
//! > align with actual glyph positions. Copy produces correct Unicode.
//! > Search finds a string and highlights the right page/position.
//!
//! These drive the real measurement path — `ResolvedFont` + `TextState` +
//! `GlyphAdvanceCache` — rather than the byte-proportional estimate the
//! review calls out.
use nigig_pdf_cos::{PdfDict, PdfObj};
use nigig_pdf_graphics::advance::{
glyph_advances, measure_advance, GlyphAdvanceCache, ResolvedFont, TextState,
};
use nigig_pdf_graphics::cid::{parse_to_unicode, CidCMap, CidWidths};
use nigig_pdf_graphics::font::GlyphWidths;
use nigig_pdf_graphics::recording::RenderCommand;
use nigig_pdf_graphics::text::PageText;
/// A proportional font: every glyph a different width, so any code that
/// assumes even spacing produces visibly wrong positions.
fn proportional() -> ResolvedFont {
let mut w = GlyphWidths::new(500.0, 500.0);
for (ch, width) in [
(b'H', 722.0),
(b'e', 556.0),
(b'l', 222.0),
(b'o', 556.0),
(b' ', 278.0),
(b'W', 944.0),
(b'r', 333.0),
(b'd', 556.0),
(b'i', 222.0),
] {
w.set_width(ch, width);
}
ResolvedFont::simple("Helv", w).with_vertical_metrics(0.718, -0.207)
}
/// A monospaced font, for the mixed-font line.
fn monospace() -> ResolvedFont {
let mut w = GlyphWidths::new(600.0, 600.0);
for c in 32u8..=126 {
w.set_width(c, 600.0);
}
ResolvedFont::simple("Cour", w).with_vertical_metrics(0.629, -0.157)
}
/// A CID font with a ToUnicode map, for the correct-Unicode assertions.
fn cid_with_unicode() -> ResolvedFont {
let mut d = PdfDict::new();
d.set("DW", PdfObj::Int(1000));
d.set(
"W",
PdfObj::Array(vec![
PdfObj::Int(1),
PdfObj::Array(vec![PdfObj::Int(500), PdfObj::Int(500), PdfObj::Int(500)]),
]),
);
// Codes 1..3 map to "N", "a", "\u{00EF}" (a non-ASCII scalar).
let to_unicode = parse_to_unicode(
br#"3 beginbfchar
<0001> <004E>
<0002> <0061>
<0003> <00EF>
endbfchar"#,
);
ResolvedFont::composite("CID", CidCMap::identity_h(), CidWidths::from_cid_font(&d))
.with_to_unicode(to_unicode)
}
fn show(text: &str, size: f64, advance: f64) -> RenderCommand {
RenderCommand::ShowTextWithMetrics {
text: text.as_bytes().to_vec(),
font_size: size,
advance_x: advance,
advance_y: 0.0,
decoded: text.to_string(),
advance_is_measured: true,
}
}
/// The exit criterion's first clause: select across a line of mixed fonts
/// and verify the rectangles track real glyph positions.
#[test]
fn selection_across_mixed_fonts_uses_real_advances() {
let prop = proportional();
let mono = monospace();
let state = TextState::with_size(12.0);
// "Hello " in a proportional font, then "World" in a monospaced one.
let run_a = measure_advance(&prop, b"Hello ", &state);
let run_b = measure_advance(&mono, b"World", &state);
// The two runs must measure differently: five monospaced glyphs at 600
// are not the same as the proportional string.
let expected_b = 5.0 * 600.0 / 1000.0 * 12.0;
assert!((run_b - expected_b).abs() < 1e-9, "monospace advance");
assert!(
(run_a - run_b).abs() > 0.1,
"a byte-proportional estimate would make these equal"
);
let commands = vec![
RenderCommand::BeginText,
RenderCommand::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 100.0, 700.0),
show("Hello ", 12.0, run_a),
show("World", 12.0, run_b),
RenderCommand::EndText,
];
let page = PageText::from_commands(&commands);
assert_eq!(page.segments().len(), 2);
// The second run must begin exactly where the first ends.
let first = &page.segments()[0];
let second = &page.segments()[1];
assert!(
(second.origin[0] - (first.origin[0] + run_a)).abs() < 1e-9,
"the second run must start at the first run's true end"
);
// Both runs sit on one line despite the different fonts.
assert_eq!(page.plain_text(), "Hello World");
// Selecting across the boundary picks up text from both runs.
let mid_a = first.origin[0] + run_a * 0.5;
let mid_b = second.origin[0] + run_b * 0.5;
let selected = page.text_in_range((mid_a, 702.0), (mid_b, 702.0));
assert!(
selected.contains("lo ") && selected.contains("Wo"),
"selection across the font boundary was {selected:?}"
);
}
#[test]
fn selection_rectangles_align_with_glyph_positions() {
let prop = proportional();
let state = TextState::with_size(10.0);
let text = b"Hello";
let advance = measure_advance(&prop, text, &state);
let per_glyph = glyph_advances(&prop, text, &state);
// Per-glyph advances must sum to the run advance, or a caret placed by
// accumulating them drifts away from the run's end.
let sum: f64 = per_glyph.iter().sum();
assert!((sum - advance).abs() < 1e-9);
// 'H' is wider than 'l': even spacing would make these equal.
assert!(
per_glyph[0] > per_glyph[2],
"H ({}) must be wider than l ({})",
per_glyph[0],
per_glyph[2]
);
let commands = vec![
RenderCommand::BeginText,
RenderCommand::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 50.0, 400.0),
show("Hello", 10.0, advance),
RenderCommand::EndText,
];
let page = PageText::from_commands(&commands);
let seg = &page.segments()[0];
// The run rectangle must span exactly the measured advance.
let rect = seg.rect();
assert!((rect[0] - 50.0).abs() < 1e-9);
assert!((rect[2] - (50.0 + advance)).abs() < 1e-9);
// Real ascent and descent straddle the baseline.
let with_metrics = seg.rect_with_metrics(0.718, -0.207);
assert!(with_metrics[3] > 400.0 && with_metrics[1] < 400.0);
assert!((with_metrics[3] - (400.0 + 0.718 * 10.0)).abs() < 1e-9);
}
/// The exit criterion's "copy produces correct Unicode" clause.
#[test]
fn copying_composite_text_produces_correct_unicode() {
let font = cid_with_unicode();
// Three two-byte codes.
let bytes = [0x00, 0x01, 0x00, 0x02, 0x00, 0x03];
assert_eq!(font.decode_codes(&bytes), vec![1, 2, 3]);
assert_eq!(
font.decode_text(&bytes).as_deref(),
Some("Na\u{00EF}"),
"ToUnicode must drive the copied text, including non-ASCII"
);
// And the advance is three glyphs, not six bytes.
let advance = measure_advance(&font, &bytes, &TextState::with_size(10.0));
assert!((advance - 3.0 * 500.0 / 1000.0 * 10.0).abs() < 1e-9);
}
#[test]
fn copying_simple_text_decodes_win_ansi() {
let font = proportional();
// 0x92 is a right single quote in WinAnsi, not U+0092.
assert_eq!(font.decode_text(b"it\x92s").as_deref(), Some("it\u{2019}s"));
}
/// The exit criterion's search clause.
#[test]
fn search_finds_a_string_and_reports_its_position() {
let prop = proportional();
let state = TextState::with_size(12.0);
let line1 = "Hello World";
let line2 = "World order";
let a1 = measure_advance(&prop, line1.as_bytes(), &state);
let a2 = measure_advance(&prop, line2.as_bytes(), &state);
let commands = vec![
RenderCommand::BeginText,
RenderCommand::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 72.0, 700.0),
show(line1, 12.0, a1),
RenderCommand::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 72.0, 680.0),
show(line2, 12.0, a2),
RenderCommand::EndText,
];
let page = PageText::from_commands(&commands);
let hits = page.find("world");
assert_eq!(hits.len(), 2, "case-insensitive across both lines");
// The hits must be on different lines, at the right vertical positions.
let first = &hits[0];
let second = &hits[1];
assert_eq!(first.segment_index, 0);
assert_eq!(second.segment_index, 1);
assert_eq!(first.text, "World");
let r1 = first.rects[0];
let r2 = second.rects[0];
assert!(r1[1] > r2[1], "line 1 sits above line 2");
// The first hit starts partway into its line; the second at the start.
assert!(r1[0] > 72.0, "\"World\" is not at the start of line 1");
assert!((r2[0] - 72.0).abs() < 1.0, "\"World\" starts line 2");
// Every rectangle stays inside its own run.
for (hit, seg) in hits.iter().zip(page.segments()) {
let bounds = seg.rect();
assert!(hit.rects[0][0] >= bounds[0] - 1e-9);
assert!(hit.rects[0][2] <= bounds[2] + 1e-9);
}
}
#[test]
fn the_advance_cache_serves_repeated_measurements_of_a_page() {
let mut cache = GlyphAdvanceCache::new(1);
cache.register_font(proportional());
cache.register_font(monospace());
let state = TextState::with_size(12.0);
// Simulate several frames re-measuring the same page.
let mut last = None;
for _ in 0..5 {
let a = cache.advance("Helv", b"Hello World", &state).expect("font");
if let Some(prev) = last {
assert_eq!(a, prev, "the same input must measure identically");
}
last = Some(a);
}
let (hits, misses) = cache.stats();
assert_eq!(misses, 1, "measured once");
assert_eq!(hits, 4, "the other four frames were served from cache");
// Different fonts do not collide in the cache.
let helv = cache.advance("Helv", b"iiiii", &state).expect("font");
let cour = cache.advance("Cour", b"iiiii", &state).expect("font");
assert!(
(helv - cour).abs() > 0.1,
"narrow proportional glyphs must not measure as monospaced"
);
// A new page revision rebuilds rather than serving stale values.
assert!(cache.sync_generation(2));
assert!(cache.is_empty());
}
#[test]
fn an_unmeasurable_run_is_flagged_rather_than_estimated() {
// The review's complaint was a silent font_size * 0.6 fallback. A run
// with no width table must be visibly unmeasured instead.
let commands = vec![RenderCommand::ShowTextWithMetrics {
text: b"unknown".to_vec(),
font_size: 12.0,
advance_x: 0.0,
advance_y: 0.0,
decoded: "unknown".into(),
advance_is_measured: false,
}];
let page = PageText::from_commands(&commands);
assert!(!page.segments()[0].advance_is_measured);
}