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 <echelon@gmail.com>
This commit is contained in:
cyfung1031 2026-10-12 08:21:24 +09:00 committed by GitHub
parent f5452cf126
commit eb32e3adf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 121 additions and 3 deletions

View File

@ -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.

View File

@ -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<usize>, Vec<u32>, Vec<bool>) {
// 1. Segments in content order. Every run is a range of consecutive glyphs.
let mut segs: Vec<Seg> = 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<usize>, Vec<u32>, Vec<bool>) {
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::<Vec<_>>(), ["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::<String>(), "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

View File

@ -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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- 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.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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<br>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. |