//! 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); }