From eb32e3adf7c8100fffb3245df7ee95d3852a1b12 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:21:24 +0900 Subject: [PATCH] M2: keep doubled short text runs from interleaving (#1198) * M2: split short whole-run redraw resets before placement * M2: reject non-finite redraw prefix positions --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/render/src/text.rs | 118 +++++++++++++++++++++++++++++++++++++- docs/roadmap.md | 4 +- 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 561932a9..4da2c748 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -136,6 +137,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged. - **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged. Newest first: the date, what moved, and the new overall estimate. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index de791e5a..ddd7b9ac 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -740,6 +740,10 @@ const REDRAW_CANDIDATES: usize = 32; /// Pair tests that the collision sweep may make per glyph of a segment. Ordinary text overlaps each glyph with about one /// neighbour, so it stays far below this. A segment that needs more is dense, and [`collide`] joins its runs instead. const COLLIDE_PAIRS_PER_GLYPH: usize = 16; +/// Glyph positions within a fifth of their height are considered the same for redraws. +const REDRAW_POSITION_TOLERANCE: f32 = 0.2; +/// A reset needs three matching glyphs at the start of the run, keeping local kerning from splitting it. +const REDRAW_PREFIX_GLYPHS: usize = 3; /// Reusable buffers, so a page allocates them once rather than per segment. Glyph placement works in them, one /// segment at a time, when the segment is emitted. @@ -818,13 +822,44 @@ fn redraws(glyphs: &[&TextGlyph], a: Run, b: Run) -> bool { a.len() == b.len() && a.range().zip(b.range()).all(|(i, j)| match (glyphs.get(i), glyphs.get(j)) { (Some(p), Some(g)) => { - let tol = 0.2 * (g.rect[3] - g.rect[1]).max(0.1); + let tol = REDRAW_POSITION_TOLERANCE * (g.rect[3] - g.rect[1]).max(0.1); p.text == g.text && (p.rect[0] - g.rect[0]).abs() < tol && (p.rect[1] - g.rect[1]).abs() < tol } _ => false, }) } +fn within_position_tolerance(a: f32, b: f32, tolerance: f32) -> bool { + (a - b).abs().partial_cmp(&tolerance) == Some(std::cmp::Ordering::Less) +} + +/// Whether `next` restarts `start` after the run has advanced by a glyph height. The fixed prefix +/// bounds this check and requires more than a single backward adjustment to split a run. +fn restarts_run(glyphs: &[&TextGlyph], start: usize, next: usize, progress: f32) -> bool { + let (Some(first), Some(candidate)) = (glyphs.get(start), glyphs.get(next)) else { return false }; + let tol = REDRAW_POSITION_TOLERANCE * (candidate.rect[3] - candidate.rect[1]).max(0.1); + if first.text != candidate.text + || !within_position_tolerance(first.rect[0], candidate.rect[0], tol) + || !within_position_tolerance(first.rect[1], candidate.rect[1], tol) + { + return false; + } + let h = (first.rect[3] - first.rect[1]).max(0.1); + if !progress.is_finite() || progress <= h { + return false; + } + for offset in 1..REDRAW_PREFIX_GLYPHS { + let (Some(a), Some(b)) = (start.checked_add(offset).and_then(|i| glyphs.get(i)), next.checked_add(offset).and_then(|i| glyphs.get(i))) else { + return false; + }; + let tol = REDRAW_POSITION_TOLERANCE * (b.rect[3] - b.rect[1]).max(0.1); + if a.text != b.text || !within_position_tolerance(a.rect[0], b.rect[0], tol) || !within_position_tolerance(a.rect[1], b.rect[1], tol) { + return false; + } + } + true +} + /// Puts a segment's glyphs in visual reading order into `sc.placed`, and its forced word breaks into `sc.breaks`. /// /// Glyphs are placed by x. Runs that collide, meaning that glyphs of different runs overprint each other, stay whole @@ -1059,6 +1094,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { // 1. Segments in content order. Every run is a range of consecutive glyphs. let mut segs: Vec = Vec::new(); + let mut run_start_x = 0.0; + let mut furthest_progress = 0.0; for i in 0..n { let g = glyphs[i].rect; let cont = segs.last().is_some_and(|s| { @@ -1067,12 +1104,19 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let gap = g[0] - glyphs[p].rect[2]; (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); - if cont && let Some(s) = segs.last_mut() { + let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); + if cont + && !reset + && let Some(s) = segs.last_mut() + { s.first.end = i + 1; union(&mut s.bbox, &g); s.h = s.h.max(height(i)); + furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs()); } else { segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i))); + run_start_x = g[0]; + furthest_progress = 0.0; } } // A run that redraws an earlier run at the same place (doubled or shadowed text) is dropped, as a doubled glyph is @@ -1606,6 +1650,69 @@ mod tests { extract_page(&text_page(ops), 0, &InterpreterSettings::default()).unwrap().plain_text() } + #[test] + fn short_doubled_word_at_same_origin_does_not_interleave() { + let ops = "BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm (world) Tj ET\n".repeat(2); + assert_single_word(page_glyphs(&ops)); + } + + #[test] + fn short_near_shadow_does_not_interleave() { + let ops = "BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm (world) Tj ET\nBT /F1 12 Tf 50 Tz 1 0 0 1 101 200 Tm (world) Tj ET\n"; + assert_single_word(page_glyphs(ops)); + } + + #[test] + fn turned_short_doubled_word_does_not_interleave() { + let one = turned(37.0, 1, 12.0, 100.0, 200.0, "50 Tz (world) Tj"); + assert_single_word(page_glyphs(&(one.clone() + &one))); + } + + #[test] + fn large_backward_tj_adjustment_stays_in_one_run() { + let text = page_glyphs("BT /F1 12 Tf 50 Tz 1 0 0 1 100 200 Tm [(wor) 3000 (ld)] TJ ET"); + assert_eq!(text.plain_text(), "ld wor"); + assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::>(), ["l", "d", "w", "o", "r"]); + assert!(text.line_of.iter().all(|line| *line == 0)); + assert_eq!(text.space_before, [false, false, true, false, false]); + } + + #[test] + fn reset_prefix_requires_one_glyph_height_of_progress() { + let mut glyphs = Vec::new(); + word(&mut glyphs, "abc", 10.0, 10.0, 6.0); + word(&mut glyphs, "abc", 10.0, 10.0, 6.0); + let glyphs: Vec<&TextGlyph> = glyphs.iter().collect(); + assert!(!restarts_run(&glyphs, 0, 3, 9.99)); + assert!(!restarts_run(&glyphs, 0, 3, 10.0)); + assert!(restarts_run(&glyphs, 0, 3, 10.01)); + } + + #[test] + fn reset_prefix_uses_the_existing_redraw_position_tolerance() { + let mut near = Vec::new(); + word(&mut near, "abc", 10.0, 10.0, 6.0); + word(&mut near, "abc", 11.99, 10.0, 6.0); + let near: Vec<&TextGlyph> = near.iter().collect(); + assert!(restarts_run(&near, 0, 3, 10.01)); + + let mut far = Vec::new(); + word(&mut far, "abc", 10.0, 10.0, 6.0); + word(&mut far, "abc", 12.01, 10.0, 6.0); + let far: Vec<&TextGlyph> = far.iter().collect(); + assert!(!restarts_run(&far, 0, 3, 10.01)); + } + + #[test] + fn reset_prefix_rejects_non_finite_positions() { + let mut glyphs = Vec::new(); + word(&mut glyphs, "abc", 10.0, 10.0, 6.0); + word(&mut glyphs, "abc", 10.0, 10.0, 6.0); + glyphs[3].rect[0] = f32::NAN; + let glyphs: Vec<&TextGlyph> = glyphs.iter().collect(); + assert!(!restarts_run(&glyphs, 0, 3, 10.01)); + } + #[test] fn turned_lines_group_along_their_baseline() { // Two lines 13 pt apart, words separated by TJ offsets (no space glyphs), the second @@ -1703,6 +1810,13 @@ mod tests { extract_page(&text_page(ops), 0, &InterpreterSettings::default()).unwrap() } + fn assert_single_word(text: PageText) { + assert_eq!(text.plain_text(), "world"); + assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::(), "world"); + assert_eq!(text.line_of, [0; 5]); + assert_eq!(text.space_before, [false; 5]); + } + #[test] fn runs_printed_over_each_other_stay_whole_at_any_angle() { // Two runs that start at one point (exact overprint), that start 40 pt along and 3 pt down diff --git a/docs/roadmap.md b/docs/roadmap.md index 2477ea75..14d55ce8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -75,7 +76,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. | @@ -139,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | | 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. | | 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. |