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:
parent
f5452cf126
commit
eb32e3adf7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue