From 031532b8a563f0dee20a2ec284f63ed15640fccb Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:01:43 +0300 Subject: [PATCH 1/3] M4: bound named-destination pruning during page deletion (#3309) --- ROADMAP.md | 3 +- crates/automation/README.md | 7 ++ crates/automation/tests/automation.rs | 84 +++++++++++++++++++++++ crates/organize/src/lib.rs | 69 ++++++++++++------- crates/organize/src/tests.rs | 96 +++++++++++++++++++++++++++ docs/roadmap.md | 6 +- parity/acrobat-features.toml | 4 +- 7 files changed, 240 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 907beb98a..a3def841d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; M4 #3309: bounded iterative destination pruning; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -169,6 +169,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. A shared 100,000-entry budget bounds nodes, names and child edges, including repeated edges; exceeding it refuses the engine edit without losing unsaved work or history. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged. - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. diff --git a/crates/automation/README.md b/crates/automation/README.md index 4e27702a3..de0d0697c 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -194,6 +194,13 @@ unapplied with a warning. folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. +## Page deletion limits + +`page_delete` prunes named destinations iteratively. Its shared traversal budget is 100,000 +nodes and `/Names` and `/Kids` array entries, counting repeated child edges. Exceeding it +returns a named-destination work-limit error; the engine keeps the PDF, unsaved edits and +undo/redo history unchanged. Deep trees within the budget do not consume recursive stack space. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f3bcfd21a..f00b963b5 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -779,6 +779,90 @@ fn edit_undo_redo_and_save_round_trip() { assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2); } +/// A shallow-object destination graph: a deep chain, or a wide repeated-child array. +fn destination_graph_fixture(depth: usize, wide: bool) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions}; + let mut doc = Document::open(std::sync::Arc::new(fixture(3))).unwrap(); + let pages = pdfcraft_organize::pages(&doc).unwrap(); + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![ + Object::String(PdfString::text("gone")), + Object::Array(vec![Object::Ref(pages[0].obj), Object::name("Fit")]), + Object::String(PdfString::text("kept")), + Object::Array(vec![Object::Ref(pages[2].obj), Object::name("Fit")]), + ]), + ); + let mut tree = doc.add(leaf); + if wide { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree); 100_000])); + tree = doc.add(node); + } else { + for _ in 0..depth { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + pdfcraft_cos::write_incremental(&doc, &SaveOptions::default()).unwrap() +} + +#[test] +fn page_delete_handles_deep_destination_trees_with_history_and_reopen() { + let dir = workdir("deep-destinations"); + let original = destination_graph_fixture(16_384, false); + std::fs::write(dir.join("deep.pdf"), &original).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"deep.pdf"}))["doc"].as_u64().unwrap(); + let render = a.call("page_render", &json!({"doc":doc,"page":2,"dpi":72})).unwrap(); + ok(&mut a, "page_delete", json!({"doc":doc,"pages":[1]})); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), render); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + ok(&mut a, "edit_redo", json!({"doc":doc})); + for full in [false, true] { + let path = format!("saved-{full}.pdf"); + ok(&mut a, "doc_save", json!({"doc":doc,"path":path,"full":full})); + let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened), ["Page 2", "Page 3"]); + assert_eq!(a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render); + } + assert_eq!(std::fs::read(dir.join("deep.pdf")).unwrap(), original); +} + +#[test] +fn page_delete_destination_budget_preserves_unsaved_work_and_history() { + let dir = workdir("wide-destinations"); + let original = destination_graph_fixture(0, true); + std::fs::write(dir.join("wide.pdf"), &original).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"wide.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Title","value":"Unsaved work"})); + let info = ok(&mut a, "doc_info", json!({"doc":doc})); + let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let bytes = before.bytes.clone(); + let generation = before.edit_generation(); + let undo = before.can_undo().map(str::to_owned); + assert!( + matches!(err(&mut a, "page_delete", json!({"doc":doc,"pages":[1]})), ToolError::Failed(message) if message.contains("named destination tree") && message.contains("100000-entry work limit")) + ); + let after = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + assert!(std::sync::Arc::ptr_eq(&after.bytes, &bytes)); + assert_eq!(after.edit_generation(), generation); + assert_eq!(after.can_undo(), undo.as_deref()); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc})), info); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(a.session().get(pdfcraft_engine::DocId(doc)).unwrap().can_undo(), None); + assert_eq!(std::fs::read(dir.join("wide.pdf")).unwrap(), original); +} + /// Issue #1329: an in-place save says it was incremental only when it kept the file it was /// saving over: protecting or sanitizing rewrites the file, and the result says so. #[test] diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 99c228c60..ff1c3d3e3 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -365,43 +365,60 @@ fn prune_legacy(doc: &Document, dests: &mut Dict, gone: &[ObjRef], dead: &mut Ve /// Prune a name tree held in `slot` (a reference, or a dictionary written inline). A reference is /// updated in place and yields `None`; an inline dictionary that changed is handed back. fn prune_tree_slot(doc: &mut Document, slot: &Object, gone: &[ObjRef], dead: &mut Vec>) -> Result, OrganizeError> { - let mut seen = Vec::new(); - match slot { + let mut stack = Vec::new(); + let mut left = MAX_DESTINATION_TREE_WORK; + let inline = match slot { Object::Ref(r) => { - prune_tree_ref(doc, *r, gone, dead, &mut seen)?; - Ok(None) + stack.push(*r); + None } Object::Dict(d) => { let mut node = d.clone(); - Ok(prune_tree_node(doc, &mut node, gone, dead, &mut seen)?.then_some(node)) + prune_tree_node(doc, &mut node, gone, dead, &mut stack, &mut left)?.then_some(node) + } + _ => None, + }; + // Name trees consist of shallow indirect nodes. Their graph depth must not become + // call-stack depth; the shared budget also bounds wide trees and repeated edges. + let mut seen = std::collections::HashSet::new(); + while let Some(r) = stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut node) = doc.get(r).as_dict().cloned() else { continue }; + if prune_tree_node(doc, &mut node, gone, dead, &mut stack, &mut left)? { + doc.update_dict(r, |d| *d = node)?; } - _ => Ok(None), } + Ok(inline) } -fn prune_tree_ref(doc: &mut Document, r: ObjRef, gone: &[ObjRef], dead: &mut Vec>, seen: &mut Vec) -> Result<(), OrganizeError> { - if seen.contains(&r) { - return Ok(()); - } - seen.push(r); - let Some(mut node) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - if prune_tree_node(doc, &mut node, gone, dead, seen)? { - doc.update_dict(r, |d| *d = node)?; - } +/// Count nodes and the entries in their /Names and /Kids arrays, including repeated edges. +const MAX_DESTINATION_TREE_WORK: usize = 100_000; + +fn destination_tree_work(left: &mut usize, count: usize) -> Result<(), OrganizeError> { + *left = left + .checked_sub(count) + .ok_or_else(|| OrganizeError::Invalid(format!("the named destination tree exceeds the {MAX_DESTINATION_TREE_WORK}-entry work limit")))?; Ok(()) } -/// Drop the /Names pairs of one name-tree node that go to a `gone` page, then do the same for its -/// /Kids. True when `node` itself changed (kids held by reference are written back here). +/// Drop the /Names pairs going to a `gone` page, and queue /Kids for the iterative walk. +/// True when `node` itself changed; child nodes are written back by the caller. fn prune_tree_node( - doc: &mut Document, + doc: &Document, node: &mut Dict, gone: &[ObjRef], dead: &mut Vec>, - seen: &mut Vec, + stack: &mut Vec, + left: &mut usize, ) -> Result { + destination_tree_work(left, 1)?; let mut changed = false; - if let Some(pairs) = node.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + if let Some(names) = node.get(b"Names").map(|n| doc.resolve(n)) + && let Some(pairs) = names.as_array() + { + destination_tree_work(left, pairs.len())?; let mut kept = Vec::with_capacity(pairs.len()); for pair in pairs.chunks(2) { match pair { @@ -418,10 +435,14 @@ fn prune_tree_node( changed = true; } } - let kids = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids { - if let Some(r) = kid.as_ref() { - prune_tree_ref(doc, r, gone, dead, seen)?; + if let Some(children) = node.get(b"Kids").map(|k| doc.resolve(k)) + && let Some(kids) = children.as_array() + { + destination_tree_work(left, kids.len())?; + for kid in kids.iter().rev() { + if let Some(r) = kid.as_ref() { + stack.push(r); + } } } Ok(changed) diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9bd3eb396..f7e023e5d 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -595,6 +595,102 @@ fn deleting_a_page_drops_the_named_destinations_to_it() { // ── Combine / extract / split ────────────────────────────────────────────────────────────────── +#[test] +fn deleting_a_page_prunes_deep_named_destination_trees() { + let mut doc = doc_a(); + let original_pages = pages(&doc).unwrap(); + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![ + Object::String(PdfString::text("gone")), + Object::Array(vec![Object::Ref(original_pages[0].obj), Object::name("Fit")]), + Object::String(PdfString::text("kept")), + Object::Array(vec![Object::Ref(original_pages[2].obj), Object::name("Fit")]), + ]), + ); + let leaf = doc.add(leaf); + let mut tree = leaf; + for _ in 0..16_384 { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + + // Every node is a shallow indirect object: the page operation must not turn the + // document's graph depth into call-stack depth. + delete_pages(&mut doc, &[0]).unwrap(); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(bytes); + assert_eq!(labels(&reopened), ["A2", "A3"]); + let catalog = reopened.get(reopened.root().unwrap()); + let names = reopened.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let mut node = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + for _ in 0..16_384 { + let child = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone(); + node = reopened.resolve(&child); + } + let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap(); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].as_string().unwrap().to_text(), "kept"); + assert_eq!(pairs[1].as_array().unwrap()[0].as_ref(), Some(pages(&reopened).unwrap()[1].obj)); + } +} + +#[test] +fn destination_pruning_visits_shared_and_cyclic_nodes_once() { + for inline_names in [false, true] { + for inline_tree in [false, true] { + let mut doc = doc_a(); + let gone = pages(&doc).unwrap()[0].obj; + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![Object::String(PdfString::text("gone")), Object::Array(vec![Object::Ref(gone), Object::name("Fit")])]), + ); + leaf.set(b"Unknown".to_vec(), Object::Int(42)); + let leaf = doc.add(leaf); + let cycle = doc.add(Object::Null); + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(cycle), Object::Ref(leaf), Object::Ref(leaf)])); + doc.set(cycle, Object::Dict(node.clone())); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), if inline_tree { Object::Dict(node) } else { Object::Ref(cycle) }); + let names = if inline_names { Object::Dict(names) } else { Object::Ref(doc.add(names)) }; + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), names)).unwrap(); + + assert_eq!(drop_named_destinations(&mut doc, &[gone]).unwrap(), [b"gone".to_vec()]); + let leaf = doc.get(leaf); + assert_eq!(leaf.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap().len(), 0); + assert_eq!(leaf.as_dict().unwrap().int(b"Unknown"), Some(42)); + } + } +} + +#[test] +fn destination_pruning_counts_repeated_edges_in_its_work_limit() { + for extra in [0, 1] { + let mut doc = doc_a(); + let leaf = doc.add(Dict::new()); + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(leaf); MAX_DESTINATION_TREE_WORK - 2 + extra])); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Dict(node)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let result = delete_pages(&mut doc, &[0]); + if extra == 0 { + result.unwrap(); + } else { + assert!( + matches!(result, Err(OrganizeError::Invalid(message)) if message.contains("named destination tree") && message.contains("100000-entry work limit")) + ); + } + } +} + /// Build a classic-xref PDF from object bodies (object i+1 = bodies[i]). fn build(bodies: &[String], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9b5100f37..e6238bd6e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M14/#2383 GPU-limit candidate; M4 #3309: bounded iterative destination pruning; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -121,7 +121,7 @@ total matches the parity estimate. | 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. 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). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). 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. 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). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). #3309: page deletion walks named destinations iteratively with a shared work budget; deep/cyclic trees and atomic engine refusal have synthetic coverage. 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. 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). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. 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. 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). Rotation queries and edits resolve indirect values on pages and ancestors. 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. | @@ -194,10 +194,12 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M4, #3309: named-destination pruning is iterative and bounded; deep-tree saves, cycles and atomic engine refusal are tested. Estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | | 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 547c5561c..d6bd8d72d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2668,8 +2668,8 @@ milestone = "M4" status = "shipped" commands = ["page.delete"] tools = ["page_delete"] -evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/editing.rs::delete_is_refused_when_it_would_remove_every_page"] -notes = "" +evidence = ["crates/organize/src/tests.rs::delete_keeps_inherited_attributes_on_survivors", "crates/organize/src/tests.rs::cannot_delete_every_page_or_missing_pages", "crates/ui-egui/tests/editing.rs::delete_is_refused_when_it_would_remove_every_page", "crates/automation/tests/automation.rs::page_delete_handles_deep_destination_trees_with_history_and_reopen", "crates/automation/tests/automation.rs::page_delete_destination_budget_preserves_unsaved_work_and_history"] +notes = "Named-destination pruning is iterative; a shared 100,000-entry work budget refuses excessive trees atomically at the engine boundary. No new destination-tree repair is attempted." [[feature]] id = "organize.move-pages" From 2448074defa9a4f3a7f379527737a5128b4318f3 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:25:45 +0300 Subject: [PATCH 2/3] M4: assert redo survives destination-budget refusal --- crates/automation/tests/automation.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f00b963b5..0da59acea 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -844,11 +844,15 @@ fn page_delete_destination_budget_preserves_unsaved_work_and_history() { let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path":"wide.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Title","value":"Unsaved work"})); + ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Author","value":"Redo probe"})); + ok(&mut a, "edit_undo", json!({"doc":doc})); let info = ok(&mut a, "doc_info", json!({"doc":doc})); let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); let bytes = before.bytes.clone(); let generation = before.edit_generation(); let undo = before.can_undo().map(str::to_owned); + let redo = before.can_redo().map(str::to_owned); + assert!(redo.is_some(), "the refusal must preserve an existing redo step"); assert!( matches!(err(&mut a, "page_delete", json!({"doc":doc,"pages":[1]})), ToolError::Failed(message) if message.contains("named destination tree") && message.contains("100000-entry work limit")) ); @@ -856,8 +860,12 @@ fn page_delete_destination_budget_preserves_unsaved_work_and_history() { assert!(std::sync::Arc::ptr_eq(&after.bytes, &bytes)); assert_eq!(after.edit_generation(), generation); assert_eq!(after.can_undo(), undo.as_deref()); + assert_eq!(after.can_redo(), redo.as_deref()); assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc})), info); assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["author"], "Redo probe"); + ok(&mut a, "edit_undo", json!({"doc":doc})); ok(&mut a, "edit_undo", json!({"doc":doc})); assert_eq!(a.session().get(pdfcraft_engine::DocId(doc)).unwrap().can_undo(), None); assert_eq!(std::fs::read(dir.join("wide.pdf")).unwrap(), original); From 33268824639728b363782ae7d255cb2e27807db7 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:54:13 +0300 Subject: [PATCH 3/3] M4: use fixed-size slices in named destination regressions --- crates/automation/tests/automation.rs | 2 +- crates/organize/src/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 118aebf44..0e75451a8 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -824,7 +824,7 @@ fn assert_destination_state(a: &Automation, doc: u64, depth: usize, count: usize let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap(); assert_eq!(pairs.len(), if deleted { count / 2 * 2 } else { count * 2 }); let expected = (0..count).filter(|i| !deleted || i % 2 == 1); - for (pair, i) in pairs.chunks_exact(2).zip(expected) { + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip(expected) { assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); let target = if i % 2 == 0 { pages[0].obj } else { pages.last().unwrap().obj }; assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(target), "destination {i}"); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c40347d17..d2720b4f2 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -308,7 +308,7 @@ fn deleting_a_page_keeps_sixty_thousand_named_destinations_usable() { let pairs = leaf.get(b"Names").unwrap().as_array().unwrap(); assert_eq!(pairs.len(), 60_000, "30,000 surviving name/value pairs"); let page = pages(&reopened).unwrap()[1].obj; - for (pair, i) in pairs.chunks_exact(2).zip((1..60_000).step_by(2)) { + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip((1..60_000).step_by(2)) { assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(page)); }