mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge PR #3311: fix(organize): prune named destinations without recursive calls
docs/roadmap.md: revision history keeps both sides' rows, newest first.
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: 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-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
|
||||
|
||||
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
|
||||
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
|
||||
@@ -192,6 +193,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
|
||||
- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. 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.
|
||||
|
||||
@@ -779,6 +779,117 @@ fn edit_undo_redo_and_save_round_trip() {
|
||||
assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2);
|
||||
}
|
||||
|
||||
/// A shallow-object destination tree: `depth` single-child nodes above `count` names.
|
||||
fn destination_graph_fixture(depth: usize, count: usize) -> Vec<u8> {
|
||||
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 pairs = (0..count)
|
||||
.flat_map(|i| {
|
||||
let page = if i % 2 == 0 { pages[0].obj } else { pages[2].obj };
|
||||
[Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])]
|
||||
})
|
||||
.collect();
|
||||
let mut leaf = Dict::new();
|
||||
leaf.set(b"Names".to_vec(), Object::Array(pairs));
|
||||
let mut tree = doc.add(leaf);
|
||||
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()
|
||||
}
|
||||
|
||||
/// Inspect the actual working bytes, not just page counts or history labels. The fixture has
|
||||
/// exactly `depth` single-child nodes, so this checks its leaf without a second general walker.
|
||||
fn assert_destination_state(a: &Automation, doc: u64, depth: usize, count: usize, deleted: bool) {
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||||
let parsed = Document::open(bytes).unwrap();
|
||||
let pages = pdfcraft_organize::pages(&parsed).unwrap();
|
||||
assert_eq!(pages.len(), if deleted { 2 } else { 3 });
|
||||
let catalog = parsed.get(parsed.root().unwrap());
|
||||
let names = parsed.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap());
|
||||
let mut node = parsed.resolve(names.as_dict().unwrap().get(b"Dests").unwrap());
|
||||
for _ in 0..depth {
|
||||
let kids = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
|
||||
assert_eq!(kids.len(), 1);
|
||||
let child = kids[0].clone();
|
||||
node = parsed.resolve(&child);
|
||||
}
|
||||
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.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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the real tools, including named destinations after undo and redo, and check both
|
||||
/// kinds of save explicitly: a full copy, followed by an incremental update of that copy.
|
||||
fn delete_destination_tree_through_tools(depth: usize, count: usize, label: &str) {
|
||||
let dir = workdir(label);
|
||||
let original = destination_graph_fixture(depth, count);
|
||||
std::fs::write(dir.join("source.pdf"), &original).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_destination_state(&a, doc, depth, count, false);
|
||||
let render = a.call("page_render", &json!({"doc":doc,"page":2,"dpi":72})).unwrap();
|
||||
ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Title","value":"Unsaved work"}));
|
||||
ok(&mut a, "page_delete", json!({"doc":doc,"pages":[1]}));
|
||||
assert_destination_state(&a, doc, depth, count, true);
|
||||
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_destination_state(&a, doc, depth, count, false);
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]);
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work");
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert_destination_state(&a, doc, depth, count, true);
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]);
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work");
|
||||
|
||||
let saved = ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf","full":true}));
|
||||
assert_eq!(saved["incremental"], false);
|
||||
let full = std::fs::read(dir.join("saved.pdf")).unwrap();
|
||||
let mut b = auto(&dir);
|
||||
let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_destination_state(&b, reopened, depth, count, true);
|
||||
assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render);
|
||||
|
||||
assert_eq!(ok(&mut b, "doc_info", json!({"doc":reopened}))["title"], "Unsaved work");
|
||||
ok(&mut b, "doc_set_info", json!({"doc":reopened,"key":"Author","value":"Incremental probe"}));
|
||||
let saved = ok(&mut b, "doc_save", json!({"doc":reopened}));
|
||||
assert_eq!(saved["incremental"], true);
|
||||
let incremental = std::fs::read(dir.join("saved.pdf")).unwrap();
|
||||
assert!(incremental.len() > full.len() && incremental.starts_with(&full));
|
||||
let mut b = auto(&dir);
|
||||
let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_destination_state(&b, reopened, depth, count, true);
|
||||
assert_eq!(page_text(&mut b, reopened), ["Page 2", "Page 3"]);
|
||||
assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render);
|
||||
let info = ok(&mut b, "doc_info", json!({"doc":reopened}));
|
||||
assert_eq!(info["title"], "Unsaved work");
|
||||
assert_eq!(info["author"], "Incremental probe");
|
||||
assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_delete_handles_deep_destination_trees_with_history_and_reopen() {
|
||||
delete_destination_tree_through_tools(16_384, 2, "deep-destinations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_delete_handles_sixty_thousand_destinations_with_history_and_reopen() {
|
||||
delete_destination_tree_through_tools(0, 60_000, "many-destinations");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
+41
-33
@@ -216,6 +216,12 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu
|
||||
}
|
||||
|
||||
/// Delete pages. Refuses to delete every page.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This operation is not transactional: an error can leave `doc` partially modified. Callers
|
||||
/// that need atomic edits (including replacement) must use a working clone and keep it only on
|
||||
/// success; discard the working clone on error. The engine does this for its history boundary.
|
||||
pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
|
||||
let all = walk(doc)?;
|
||||
check(indices, all.len())?;
|
||||
@@ -365,43 +371,42 @@ 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<Vec<u8>>) -> Result<Option<Dict>, OrganizeError> {
|
||||
let mut seen = Vec::new();
|
||||
match slot {
|
||||
let mut stack = Vec::new();
|
||||
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).then_some(node)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Name trees consist of shallow indirect nodes: graph depth must not become call-stack
|
||||
// depth. Visit each indirect node once, even when cyclic or shared. Unlike the depth-limited
|
||||
// outline::name_tree lookup, deletion must inspect every reachable node to remove references
|
||||
// to freed pages. Both walks use seen sets to stop cycles.
|
||||
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) {
|
||||
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<Vec<u8>>, seen: &mut Vec<ObjRef>) -> 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)?;
|
||||
}
|
||||
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).
|
||||
fn prune_tree_node(
|
||||
doc: &mut Document,
|
||||
node: &mut Dict,
|
||||
gone: &[ObjRef],
|
||||
dead: &mut Vec<Vec<u8>>,
|
||||
seen: &mut Vec<ObjRef>,
|
||||
) -> Result<bool, OrganizeError> {
|
||||
/// 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: &Document, node: &mut Dict, gone: &[ObjRef], dead: &mut Vec<Vec<u8>>, stack: &mut Vec<ObjRef>) -> bool {
|
||||
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()
|
||||
{
|
||||
let mut kept = Vec::with_capacity(pairs.len());
|
||||
for pair in pairs.chunks(2) {
|
||||
match pair {
|
||||
@@ -418,13 +423,16 @@ 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()
|
||||
{
|
||||
for kid in kids.iter().rev() {
|
||||
if let Some(r) = kid.as_ref() {
|
||||
stack.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
changed
|
||||
}
|
||||
|
||||
/// Take widgets out of the form: out of their field's /Kids, or out of /AcroForm /Fields when
|
||||
|
||||
@@ -202,6 +202,119 @@ fn delete_keeps_inherited_attributes_on_survivors() {
|
||||
assert_eq!(root.as_dict().unwrap().int(b"Count"), Some(2));
|
||||
}
|
||||
|
||||
#[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 deleting_a_page_keeps_sixty_thousand_named_destinations_usable() {
|
||||
let mut doc = doc_a();
|
||||
let original_pages = pages(&doc).unwrap();
|
||||
let pairs: Vec<Object> = (0..60_000)
|
||||
.flat_map(|i| {
|
||||
let page = if i % 2 == 0 { original_pages[0].obj } else { original_pages[2].obj };
|
||||
[Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])]
|
||||
})
|
||||
.collect();
|
||||
let mut leaf = Dict::new();
|
||||
leaf.set(b"Names".to_vec(), Object::Array(pairs));
|
||||
leaf.set(b"Unknown".to_vec(), Object::Int(42));
|
||||
let leaf = doc.add(leaf);
|
||||
let mut names = Dict::new();
|
||||
names.set(b"Dests".to_vec(), Object::Ref(leaf));
|
||||
doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap();
|
||||
|
||||
// A valid large book exceeds the former 100,000-entry budget (two entries per name).
|
||||
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 leaf = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap());
|
||||
let leaf = leaf.as_dict().unwrap();
|
||||
assert_eq!(leaf.int(b"Unknown"), Some(42));
|
||||
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.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_pages_are_not_kept_by_what_points_at_them() {
|
||||
// A1 links to A3, a bookmark goes to A3, and A2 holds the form's only field. After deleting
|
||||
|
||||
+4
-2
@@ -50,6 +50,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; 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 (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
|
||||
|
||||
Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind
|
||||
it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md),
|
||||
@@ -127,7 +128,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<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, 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:<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, 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). Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Missing: page transitions, recovery on the web |
|
||||
| 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, 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:<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, 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:<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, 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. |
|
||||
@@ -206,8 +207,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
|
||||
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; unknown names and foreign registries do not establish a mapping route. Synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate; estimates unchanged. |
|
||||
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
|
||||
| 2026-10-19 | minor | M4 #3309: named-destination pruning keeps large documents usable; 60,000-name and deep-tree regressions verify targets through undo/redo and both save modes. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
|
||||
|
||||
@@ -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/organize/src/tests.rs::deleting_a_page_keeps_sixty_thousand_named_destinations_usable", "crates/automation/tests/automation.rs::page_delete_handles_deep_destination_trees_with_history_and_reopen", "crates/automation/tests/automation.rs::page_delete_handles_sixty_thousand_destinations_with_history_and_reopen"]
|
||||
notes = "Named-destination pruning is iterative and visits shared/cyclic indirect nodes once, without a name-count work limit. Deep trees and 60,000 named destinations retain surviving targets through delete, undo/redo and full/incremental save/reopen. No new destination-tree repair is attempted."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.move-pages"
|
||||
|
||||
Reference in New Issue
Block a user