From c10e7b8f0c4912a2033b2d1e8e63fdc883db6b15 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 15 Oct 2026 06:57:34 +0000 Subject: [PATCH] Preserve literal spaces in page-label prefixes (#2355) * M0: update rustls to 0.23.46 Co-authored-by: Codex * M2: preserve literal page-label prefix spaces Keep label prefixes separate from document metadata trimming and cover literal spaces through public inspection. Co-authored-by: Codex --------- Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/render/src/inspect.rs | 84 +++++++++++++++++++++++++++++++++++- docs/roadmap.md | 2 +- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index edc432304a..a4e2648bcb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. - **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. - **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. - **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given. diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index c1fee6f007..3c3988b7b5 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -984,11 +984,19 @@ impl<'a> Inspector<'a> { for (start, d) in ranges { let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _) | Object::Name(raw)) => { + Some(Object::String(raw, _)) => { if raw.len() > MAX_PREFIX_BYTES { return Err("a raw prefix exceeds 2050 bytes"); } - self.text(d, b"P").unwrap_or_default() + // A label prefix is literal text, not trimmed document metadata. + text_string(raw).trim_matches('\0').to_string() + } + Some(Object::Name(raw)) => { + if raw.len() > MAX_PREFIX_BYTES { + return Err("a raw prefix exceeds 2050 bytes"); + } + // Retain the existing name compatibility and boundary-NUL handling. + String::from_utf8_lossy(raw).trim_matches('\0').to_string() } _ => String::new(), }; @@ -1940,6 +1948,78 @@ mod tests { assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); } + // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. + fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { + use lopdf::dictionary; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let kids: Vec = (0..3) + .map(|_| { + doc.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Resources" => Dictionary::new(), + }) + .into() + }) + .collect(); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); + let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); + let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; + let mut label = Dictionary::new(); + label.set("P", prefix); + if decimal { + label.set("S", Object::Name(b"D".to_vec())); + } + let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; + let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); + let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); + doc.trailer.set("Root", catalog_id); + doc.trailer.set("Info", info_id); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).expect("write original page-prefix fixture"); + bytes + } + + #[test] + fn page_label_prefix_spaces_survive_public_inspection() { + let mut utf16 = vec![0xfe, 0xff]; + utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); + for (raw, prefix, decimal, indirect) in [ + (b" leading".to_vec(), " leading", true, false), + (b"trailing ".to_vec(), "trailing ", true, false), + (b" both ".to_vec(), " both ", false, false), + (b" ".to_vec(), " ", false, false), + (b" Caf\xe9 ".to_vec(), " Café ", true, false), + (utf16.clone(), " Ω ", true, false), + (utf16, " Ω ", false, true), + (Vec::new(), "", true, false), + (Vec::new(), "", false, false), + ] { + let bytes = page_prefix_space_pdf(raw, decimal, indirect); + let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); + let expected: Vec = (1..=3) + .map(|n| { + if decimal { + format!("{prefix}{n}") + } else if prefix.is_empty() { + n.to_string() + } else { + prefix.to_owned() + } + }) + .collect(); + assert_eq!( + info.pages.iter().map(|p| p.label.clone()).collect::>(), + expected, + "{prefix:?}, decimal={decimal}, indirect={indirect}" + ); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); + } + } + #[test] fn page_label_limits_keep_normal_pdf_numbering() { for (style, first, expected) in [ diff --git a/docs/roadmap.md b/docs/roadmap.md index bf9eab373b..39c495db9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -105,7 +105,7 @@ total matches the parity estimate. | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #2080 candidate: search-result jumps enter per-tab history; rail and control-channel Back/Forward; no exact-view or restart persistence. | | 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. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). 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); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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 | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | 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, an editable page-range selection (#1092), 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. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | | 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, an editable page-range selection (#1092), 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. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |