diff --git a/ROADMAP.md b/ROADMAP.md index 9b68d22e93..a53036fce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** 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 (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) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what @@ -135,6 +136,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. - **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged. - **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index abd57b97d9..575affb446 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -14,7 +14,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; -use crate::{EditError, check, contents, n, page_list, place_tagged}; +use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged}; const TAG: &str = "Added"; @@ -198,6 +198,13 @@ pub struct Added { pub content: Content, } +/// PdfCraft-owned page content discovered in one page/content traversal. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MarkerScan { + pub marks: Vec, + pub added: Vec, +} + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() } @@ -747,6 +754,76 @@ fn parse(doc: &Document, d: &Dict) -> Option { } } +fn with_resolved(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T { + match object { + Object::Ref(reference) => { + let resolved = doc.get(*reference); + f(&resolved) + } + direct => f(direct), + } +} + +fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) { + let Some(contents) = page.dict.get(b"Contents") else { return }; + with_resolved(doc, contents, |resolved| match resolved { + Object::Array(entries) => { + for entry in entries { + visit(entry); + } + } + _ => visit(contents), + }); +} + +/// Finds page marks and editable added content in one pass over the page content entries. +pub fn scan_markers(doc: &Document) -> MarkerScan { + let mut scan = MarkerScan::default(); + for (pi, page) in page_list(doc).iter().enumerate() { + for_each_content_entry(doc, page, |entry| { + with_resolved(doc, entry, |resolved| { + let Some(dict) = resolved.as_dict() else { return }; + let tag = dict.name(b"PCMark"); + let kind = if tag == Some(&b"HeaderFooter"[..]) { + Some(MarkKind::HeaderFooter) + } else if tag == Some(&b"Watermark"[..]) { + Some(MarkKind::Watermark) + } else if tag == Some(&b"Background"[..]) { + Some(MarkKind::Background) + } else { + None + }; + if let Some(kind) = kind + && !scan.marks.contains(&kind) + { + scan.marks.push(kind); + } + + let Some(obj) = Object::as_ref(entry) else { return }; + if tag != Some(TAG.as_bytes()) { + return; + } + let Some(params_object) = dict.get(b"PCAdded") else { return }; + with_resolved(doc, params_object, |resolved_params| { + let Some(params) = resolved_params.as_dict() else { return }; + if let Some(content) = parse(doc, params) { + // Display points depend on the page's /UserUnit. An item records the one it was + // written for; none means 1, as for items written before display space followed + // /UserUnit. + let written = params + .get(b"UserUnit") + .and_then(Object::as_f64) + .filter(|u| u.is_finite() && *u >= 1.0) + .map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT)); + scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) }); + } + }); + }); + }); + } + scan +} + fn validate(c: &Content) -> Result<(), EditError> { let r = c.rect(); if !r.iter().all(|v| v.is_finite()) { @@ -835,37 +912,7 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> R /// Every added item, page by page, in drawing order. pub fn list_added(doc: &Document) -> Vec { - let mut out = Vec::new(); - for (pi, p) in page_list(doc).iter().enumerate() { - let list: Vec = match p.dict.get(b"Contents") { - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - None => Vec::new(), - }; - for o in list { - let Some(r) = o.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - if d.name(b"PCMark") != Some(TAG.as_bytes()) { - continue; - } - let Some(params) = d.get(b"PCAdded").and_then(|o| doc.resolve(o).as_dict().cloned()) else { continue }; - if let Some(c) = parse(doc, ¶ms) { - // Display points depend on the page's /UserUnit. An item records the one it was - // written for; none means 1, as for items written before display space followed - // /UserUnit. - let written = params - .get(b"UserUnit") - .and_then(Object::as_f64) - .filter(|u| u.is_finite() && *u >= 1.0) - .map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT)); - out.push(Added { page: pi, obj: r, content: c.scaled(p.user_unit(doc) / written) }); - } - } - } - out + scan_markers(doc).added } /// Add an item to a page (0-based). Returns its index among the page's added items. diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 8acd7b2aa1..0e97c63e32 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -619,24 +619,7 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu /// Which kinds of marks the document has (for enabling Update/Remove). pub fn marks_present(doc: &Document) -> Vec { - let mut found = Vec::new(); - for page in page_list(doc) { - let obj = doc.get(page.obj); - let Some(c) = obj.as_dict().and_then(|d| d.get(b"Contents").cloned()) else { continue }; - let list = match &*doc.resolve(&c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Some(t) = stream_tag(doc, &o) - && let Some(kind) = [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background].into_iter().find(|m| m.tag() == t) - && !found.contains(&kind) - { - found.push(kind); - } - } - } - found + added::scan_markers(doc).marks } pub mod objects; @@ -649,7 +632,10 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag pub mod text; pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; pub mod added; -pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content}; +pub use added::{ + Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers, + update_content, +}; #[cfg(test)] mod tests; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e6fe042e4b..720b6fcd7e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -267,6 +267,170 @@ fn added_items_follow_the_page_user_unit() { assert_eq!((old.rect[0], old.rect[2], old.rect[3], old.size), (144.0, 600.0, 1400.0, 28.0), "scaled to points"); } +fn tagged_dictionary(tag: &str, added: Option) -> Dict { + let mut dict = Dict::new(); + dict.set(b"PCMark".to_vec(), Object::name(tag)); + if let Some(added) = added { + dict.set(b"PCAdded".to_vec(), added); + } + dict +} + +fn tagged_stream(doc: &mut Document, tag: &str, added: Option) -> Object { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged_dictionary(tag, added), Vec::new())))) +} + +fn added_text_params() -> Dict { + let mut params = Dict::new(); + params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 40].into_iter().map(Object::Int).collect())); + params.set(b"Kind".to_vec(), Object::name("Text")); + params.set(b"Text".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Added text"))); + params.set(b"Size".to_vec(), Object::Int(12)); + params +} + +fn added_image_params(image: pdfcraft_cos::ObjRef) -> Dict { + let mut params = Dict::new(); + params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 70].into_iter().map(Object::Int).collect())); + params.set(b"Kind".to_vec(), Object::name("Image")); + params.set(b"Image".to_vec(), Object::Ref(image)); + params +} + +fn set_page_contents(doc: &mut Document, contents: Object, user_unit: Option) { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.update_dict(page, |dict| { + dict.set(b"Contents".to_vec(), contents); + if let Some(user_unit) = user_unit { + dict.set(b"UserUnit".to_vec(), user_unit); + } + }) + .unwrap(); +} + +fn assert_marker_scan_matches_legacy(doc: &Document, label: &str, marks: &[MarkKind], added: usize) -> MarkerScan { + let expected_marks = marks_present(doc); + let expected_added = list_added(doc); + let scan = scan_markers(doc); + assert_eq!(scan.marks, expected_marks, "{label}: marks differ from the independent scan"); + assert_eq!(scan.added, expected_added, "{label}: added content differs from the independent scan"); + assert_eq!(scan.marks, marks, "{label}: fixture's mark order or deduplication is wrong"); + assert_eq!(scan.added.len(), added, "{label}: fixture's added-item count is wrong"); + scan +} + +#[test] +fn combined_marker_scan_matches_the_independent_scans() { + let empty = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"]); + assert_marker_scan_matches_legacy(&empty, "empty page", &[], 0); + assert_marker_scan_matches_legacy(&fixture(), "ordinary streams and a page without contents", &[], 0); + + for (tag, kind) in [("HeaderFooter", MarkKind::HeaderFooter), ("Watermark", MarkKind::Watermark), ("Background", MarkKind::Background)] { + let mut doc = fixture(); + let marker = tagged_stream(&mut doc, tag, None); + set_page_contents(&mut doc, marker, None); + assert_marker_scan_matches_legacy(&doc, tag, &[kind], 0); + } + + let mut all_marks = fixture(); + let background = tagged_stream(&mut all_marks, "Background", None); + let watermark = tagged_stream(&mut all_marks, "Watermark", None); + let header = tagged_stream(&mut all_marks, "HeaderFooter", None); + set_page_contents( + &mut all_marks, + Object::Array(vec![background.clone(), watermark.clone(), header.clone(), watermark, header, background]), + None, + ); + assert_marker_scan_matches_legacy(&all_marks, "all mark kinds repeated", &[MarkKind::Background, MarkKind::Watermark, MarkKind::HeaderFooter], 0); + + let mut single_reference = fixture(); + let marker = tagged_stream(&mut single_reference, "Watermark", None); + set_page_contents(&mut single_reference, marker, None); + assert_marker_scan_matches_legacy(&single_reference, "contents as one reference", &[MarkKind::Watermark], 0); + + let mut text_only = fixture(); + let text = tagged_stream(&mut text_only, "Added", Some(Object::Dict(added_text_params()))); + set_page_contents(&mut text_only, text, None); + let text_scan = assert_marker_scan_matches_legacy(&text_only, "one added text item", &[], 1); + assert!(matches!(&text_scan.added[0].content, Content::Text(text) if text.text == "Added text")); + + let mut indirect_params = fixture(); + let params = indirect_params.add(Object::Dict(added_text_params())); + let text = tagged_stream(&mut indirect_params, "Added", Some(Object::Ref(params))); + set_page_contents(&mut indirect_params, text, None); + let indirect_scan = assert_marker_scan_matches_legacy(&indirect_params, "indirect PCAdded dictionary", &[], 1); + assert!(matches!(&indirect_scan.added[0].content, Content::Text(text) if text.text == "Added text")); + + let mut image_only = fixture(); + let image = image_only.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0]))); + let image_item = tagged_stream(&mut image_only, "Added", Some(Object::Dict(added_image_params(image)))); + set_page_contents(&mut image_only, image_item, None); + let image_scan = assert_marker_scan_matches_legacy(&image_only, "one added image item", &[], 1); + assert!(matches!(&image_scan.added[0].content, Content::Image(item) if item.image == image)); + + let mut interleaved = fixture(); + let text = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_text_params()))); + let watermark = tagged_stream(&mut interleaved, "Watermark", None); + let image = interleaved.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0]))); + let image_item = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_image_params(image)))); + let header = tagged_stream(&mut interleaved, "HeaderFooter", None); + set_page_contents(&mut interleaved, Object::Array(vec![text, watermark, image_item, header]), None); + let interleaved_scan = + assert_marker_scan_matches_legacy(&interleaved, "marks and added content interleaved", &[MarkKind::Watermark, MarkKind::HeaderFooter], 2); + assert!(matches!(&interleaved_scan.added[0].content, Content::Text(_))); + assert!(matches!(&interleaved_scan.added[1].content, Content::Image(_))); + + let mut array_contents = fixture(); + let background = tagged_stream(&mut array_contents, "Background", None); + let array_ref = array_contents.add(Object::Array(vec![background])); + set_page_contents(&mut array_contents, Object::Ref(array_ref), None); + assert_marker_scan_matches_legacy(&array_contents, "contents as an indirect array", &[MarkKind::Background], 0); + + let mut malformed = fixture(); + let indirect_mark = malformed.add(Object::Dict(tagged_dictionary("HeaderFooter", None))); + let missing_added = tagged_stream(&mut malformed, "Added", None); + let invalid_added = tagged_stream(&mut malformed, "Added", Some(Object::String(pdfcraft_cos::PdfString::text("bad params")))); + let unknown = tagged_stream(&mut malformed, "FutureKind", None); + let direct_watermark = Object::Dict(tagged_dictionary("Watermark", None)); + let direct_added = Object::Dict(tagged_dictionary("Added", Some(Object::Dict(added_text_params())))); + set_page_contents( + &mut malformed, + Object::Array(vec![ + Object::Null, + Object::Int(7), + Object::name("not-a-stream"), + direct_watermark, + Object::Ref(indirect_mark), + missing_added, + invalid_added, + unknown, + direct_added, + ]), + None, + ); + assert_marker_scan_matches_legacy( + &malformed, + "malformed, non-stream, unknown and direct entries", + &[MarkKind::Watermark, MarkKind::HeaderFooter], + 0, + ); + + let mut shared = fixture(); + let added = tagged_stream(&mut shared, "Added", Some(Object::Dict(added_text_params()))); + let mark = tagged_stream(&mut shared, "Background", None); + set_page_contents(&mut shared, Object::Array(vec![added.clone(), mark.clone(), added.clone(), mark]), None); + let shared_scan = assert_marker_scan_matches_legacy(&shared, "repeated shared stream references", &[MarkKind::Background], 2); + assert_eq!(shared_scan.added[0].obj, shared_scan.added[1].obj); + + let mut user_unit = fixture(); + let added = tagged_stream(&mut user_unit, "Added", Some(Object::Dict(added_text_params()))); + set_page_contents(&mut user_unit, added, Some(Object::Int(2))); + let unit_scan = assert_marker_scan_matches_legacy(&user_unit, "non-1.0 UserUnit", &[], 1); + let Content::Text(text) = &unit_scan.added[0].content else { panic!() }; + assert_eq!(text.rect, [20.0, 40.0, 220.0, 80.0]); + assert_eq!(text.size, 24.0); +} + #[test] fn added_text_and_images_are_page_content_that_stays_editable() { let mut doc = fixture(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2a16c29901..e151994f82 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2428,8 +2428,13 @@ impl Session { }; e.cos.note_repair(line); } - let marks = editor.as_ref().map(|e| pdfcraft_edit::marks_present(&e.cos)).unwrap_or_default(); - let added = editor.as_ref().map(|e| pdfcraft_edit::list_added(&e.cos)).unwrap_or_default(); + let (marks, added) = editor + .as_ref() + .map(|e| { + let scan = pdfcraft_edit::scan_markers(&e.cos); + (scan.marks, scan.added) + }) + .unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); @@ -2715,8 +2720,9 @@ impl Session { let mut form = pdfcraft_forms::fields(&editor.cos); xfa::mark_script_buttons(&editor.cos, &mut form); doc.form = Arc::new(form); - doc.marks = pdfcraft_edit::marks_present(&editor.cos); - doc.added = pdfcraft_edit::list_added(&editor.cos); + let marker_scan = pdfcraft_edit::scan_markers(&editor.cos); + doc.marks = marker_scan.marks; + doc.added = marker_scan.added; doc.links = pdfcraft_annot::links::list(&editor.cos); doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); doc.display = display_bytes(Some(editor), &bytes); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fdafc7085b..838a42b137 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1327,6 +1327,56 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +#[test] +fn open_populates_marks_and_added_from_the_combined_marker_scan() { + let (mut source, source_id) = session_with(1); + let mut header = HeaderFooter::default(); + header.text[0] = "Header".into(); + source.apply(source_id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap(); + source + .apply( + source_id, + Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false }, + ) + .unwrap(); + source + .apply( + source_id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.9, 0.9, 1.0], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + source + .apply( + source_id, + Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), ..Default::default() } }, + ) + .unwrap(); + + let bytes = source.save_bytes(source_id).unwrap(); + let reference = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let expected_marks = pdfcraft_edit::marks_present(&reference); + let expected_added = pdfcraft_edit::list_added(&reference); + source.mark_saved(source_id, bytes.clone(), None).unwrap(); + let refreshed = source.get(source_id).unwrap(); + assert_eq!(refreshed.marks, expected_marks); + assert_eq!(refreshed.added, expected_added); + let mut reopened = Session::new(); + let reopened_id = reopened.open("marked.pdf", None, bytes, None).unwrap(); + let opened = reopened.get(reopened_id).unwrap(); + assert_eq!(opened.marks, expected_marks); + assert_eq!(opened.added, expected_added); + assert_eq!(opened.marks.len(), 3); + assert!(opened.marks.contains(&MarkKind::HeaderFooter)); + assert!(opened.marks.contains(&MarkKind::Watermark)); + assert!(opened.marks.contains(&MarkKind::Background)); + assert_eq!(opened.added.len(), 1); +} + #[test] fn export_images_and_text_follow_the_working_file() { let (mut s, id) = session_with(2);