diff --git a/README.md b/README.md index bd33ad23be..2a65b66457 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

PdfCraft

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 732668b4de..0283d11429 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,6 +7,7 @@ > **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) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -176,7 +177,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-18 (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. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. +- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. - **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background puts the original back unless the copy was edited since. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. +- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7ee10e7135..129869de70 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -380,7 +380,8 @@ impl Automation { } "page_duplicate" => { let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DuplicatePages { pages })? + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? } "page_set_box" => self.page_set_box(&a)?, "page_transition_set" => self.page_transition_set(&a)?, @@ -1817,12 +1818,17 @@ impl Automation { /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; let n = self.doc(a)?.info.pages.len(); - let at = a.int(key)?; if at < 1 || at as usize > n + 1 { return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); } - Ok(at as usize - 1) + Ok(Some(at as usize - 1)) } fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ffa0506cb7..114f098a5f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -825,9 +825,9 @@ pub fn tools() -> Vec { t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.") .cmd("page.replace") .with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])), - t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") + t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") - .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])), t( "page_transition_set", "Set page transitions", diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6a348e0d8c..f3bcfd21a4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2189,6 +2189,19 @@ fn reversing_pages_through_tools() { assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); } +#[test] +fn page_duplicate_at_inserts_the_copies_there() { + let dir = workdir("duplicate-at"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 })); + assert_eq!(r["pages"], 4); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]); + // `at` is 1-based and within 1..=pages+1, as for the other page inserts. + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based"); + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d072ae2c23..ece12e0e80 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1295,9 +1295,11 @@ pub enum Edit { width: f64, height: f64, }, - /// Copies of `pages` inserted after the last of them. + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. DuplicatePages { pages: Vec, + at: Option, }, /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the /// pages' links, comments and bookmarks stay. @@ -1962,7 +1964,7 @@ impl Edit { Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), Edit::ReversePages { .. } => Label::of("Reverse pages"), Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), Edit::SetPageBox { .. } => Label::of("Set page boxes"), @@ -2349,7 +2351,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, Edit::ReplacePages { pages, name, bytes, src_pages } => { let src = open_source(name, bytes)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5ffac5001..0f012d9e09 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -870,7 +870,7 @@ fn form_with_field(name: &str) -> Vec { fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap(); - s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap(); let doc = s.get(id).unwrap(); assert_eq!(doc.info.pages.len(), 2); assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>()); @@ -2183,7 +2183,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() { assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); - s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); } diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index e120d486b1..99c228c60f 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -499,9 +499,10 @@ pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organi rebuild(doc, &all) } -/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing +/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order +/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing /// fonts and images with the originals. -pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { +pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option) -> Result<(), OrganizeError> { let n = page_count(doc)?; check(indices, n)?; let Some(&last) = indices.iter().max() else { return Ok(()) }; @@ -509,7 +510,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga order.sort_unstable(); order.dedup(); let src = doc.clone(); - import_pages(doc, &src, &order, last + 1).map(|_| ()) + import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ()) } /// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 32a386d221..9bd3eb396a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1971,11 +1971,24 @@ fn page_boxes_default_inherit_and_set() { #[test] fn duplicate_pages_inserts_copies_after_the_last() { let mut doc = open(fixture()); - duplicate_pages(&mut doc, &[0, 1]).unwrap(); + duplicate_pages(&mut doc, &[0, 1], None).unwrap(); let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); } +#[test] +fn duplicate_pages_at_inserts_the_copies_there() { + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[2], Some(0)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]); + // An index past the end lands at the end. + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[0], Some(99)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]); +} + #[test] fn replace_pages_swaps_content_but_keeps_annotations() { // Page 2 of the target gets an annotation; replacing its content keeps it. @@ -2582,7 +2595,7 @@ fn import_can_merge_into_same_named_fields_instead() { #[test] fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut doc = doc_with_field("Text1"); - crate::duplicate_pages(&mut doc, &[0]).unwrap(); + crate::duplicate_pages(&mut doc, &[0], None).unwrap(); assert_eq!(crate::page_count(&doc).unwrap(), 2); assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed"); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 00ff0eb3a5..b5ca6abab8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4862,6 +4862,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { Some(if p.x < r.center().x { *i } else { i + 1 }) } +/// Ctrl (Option on macOS) held when a page drag drops copies the dragged pages into the gap +/// instead of moving them, as Acrobat's Organize Pages does. +/// +/// The OS comes from the context, not `cfg!`: the wasm build runs on macOS too, where +/// `cfg!(target_os = "macos")` is false, so Option has to copy there as well (#3092 review). +fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool { + if crate::commands::mac_shortcuts(ctx) { m.alt } else { m.ctrl } +} + /// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); @@ -5093,6 +5102,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool } // While dragging: the gap the pages would go to, drawn as a bar. if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) { + // Ctrl (Option on macOS) held over the drop copies the pages; see #2997. + let copying = copy_modifier(ui.ctx(), &ui.input(|i| i.modifiers)); if let Some(gap) = drop_gap(&cells, p) { let x = match cells.iter().find(|(i, _)| *i == gap) { Some((_, r)) => r.left() + 3.0, @@ -5100,8 +5111,18 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool }; let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0)); ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent)); + let mut text_at = p + vec2(14.0, 14.0); + if copying { + // A "+" by the pointer: this drop copies the pages, it doesn't move them. + let badge = p + vec2(12.0, 12.0); + ui.painter().circle(badge, 9.0, t.accent, Stroke::new(1.0, t.accent)); + let ink = Stroke::new(1.5, Color32::WHITE); + ui.painter().line_segment([badge - vec2(4.0, 0.0), badge + vec2(4.0, 0.0)], ink); + ui.painter().line_segment([badge - vec2(0.0, 4.0), badge + vec2(0.0, 4.0)], ink); + text_at.x += 18.0; + } ui.painter().text( - p + vec2(14.0, 14.0), + text_at, Align2::LEFT_TOP, if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) }, theme::medium(12.0), @@ -5111,13 +5132,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if drop { view.org_drag = None; if let Some(gap) = drop_gap(&cells, p) { - // `to` counts positions without the moving pages. - let to = gap - pages.iter().filter(|x| **x < gap).count(); - let first = pages[0]; - let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1); - if !(contiguous && to == first) { - view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to }); - view.selected = (to..to + pages.len()).collect(); + if copying { + // The copies land at the gap as it is: nothing leaves its place. + view.pending_edit = Some(Edit::DuplicatePages { pages: pages.clone(), at: Some(gap) }); + view.selected = (gap..gap + pages.len()).collect(); + } else { + // `to` counts positions without the moving pages. + let to = gap - pages.iter().filter(|x| **x < gap).count(); + let first = pages[0]; + let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1); + if !(contiguous && to == first) { + view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to }); + view.selected = (to..to + pages.len()).collect(); + } } } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d32aedeef9..a658443e6f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -628,7 +628,7 @@ impl PdfCraftApp { "create.clipboard" => self.create_from_clipboard(), "optimize.reduce" => self.reduce_file_size(), "page.duplicate" => { - self.apply_edit(Edit::DuplicatePages { pages: targets }); + self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); } // Two or more selected pages reverse among themselves; otherwise the whole document. "page.reverse" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 630d66190c..a4ab35d969 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -410,12 +410,23 @@ impl Control { let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize; let modifiers = modifiers(p.get("modifiers"))?; let which = pointer_button(p, "ui.drag")?; + // The events carry the modifiers and so does the input state: gestures that read + // `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them. + let held = |mut events: Vec| { + if modifiers != egui::Modifiers::NONE { + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + events + }; let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers }; - let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]]; + let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])]; for k in 1..=steps { - frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]); + frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))])); + } + frames.push(held(vec![button(to, false)])); + if modifiers != egui::Modifiers::NONE { + frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); } - frames.push(vec![button(to, false)]); let n = self.inject(frames); Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }))) } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4d1f723c8a..e554d7a1b0 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -133,6 +133,11 @@ impl PdfCraftApp { let to = to.min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } + Edit::DuplicatePages { pages, at: Some(at) } => { + let n = pages.iter().collect::>().len(); + let at = at.min(info.pages.len().saturating_sub(n)); + view.select_pages(&(at..at + n).collect::>()); + } Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), Edit::DeletePages { .. } => view.select_pages(&[]), // A stroke drawn with the pen stays unselected, so the selection box and diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 88ed1c0a90..e6af166d77 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -5,10 +5,23 @@ use std::sync::{Arc, Mutex}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; use pdfcraft_ui_egui::control::{ControlClient, Reply}; use serde_json::{Value, json}; +/// The text each page shows, in order, as the app's session holds it. +fn page_texts(app: &PdfCraftApp) -> Vec { + let doc = app.session.get(app.views[0].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect() +} + fn fixture(n: usize) -> Vec { let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); @@ -1034,6 +1047,45 @@ fn state_reports_the_selected_pages() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([])); } +#[test] +fn ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page() { + let (mut h, c) = harness_pages(3); + ok(&mut h, &c, "ui.set", json!({ "key": "tool", "value": "organize" })); + h.run_steps(3); + // The grid cell of a page: the node labelled exactly "Page N" (the checkbox says "Select page N"). + let cell = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, page: usize| -> [f64; 4] { + let nodes = ok(h, c, "ui.inspect", json!({ "query": &format!("page {page}") })); + let want = format!("Page {page}"); + let r = nodes["widgets"] + .as_array() + .unwrap() + .iter() + .find(|w| w["label"].as_str() == Some(want.as_str())) + .and_then(|w| w["rect"].as_array().cloned()) + .expect("the page's grid cell"); + let f = |i: usize| r[i].as_f64().unwrap(); + [f(0), f(1), f(2), f(3)] + }; + let (a, b) = (cell(&mut h, &c, 1), cell(&mut h, &c, 3)); + // The gesture reads the OS the context reports: Option on macOS, Ctrl elsewhere. + let copy = if h.ctx.os() == egui::os::OperatingSystem::Mac { "alt" } else { "ctrl" }; + ok( + &mut h, + &c, + "ui.drag", + json!({ + "from": [(a[0] + a[2]) / 2.0, (a[1] + a[3]) / 2.0], + "to": [b[2] - 4.0, (b[1] + b[3]) / 2.0], + "modifiers": [copy], + }), + ); + h.run_steps(3); + let st = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(st["documents"][0]["pages"], 4, "the drag copied the page instead of moving it: {st}"); + assert_eq!(st["active"]["selected_pages"], json!([4]), "{st}"); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 1"], "the copy landed at the drop gap, after page 3"); +} + #[test] fn organize_overview_is_reachable_through_the_control_channel() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 680d49a777..2ef52a4403 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2390,6 +2390,81 @@ fn dragging_thumbnails_reorders_pages() { assert_eq!(sel, vec![2], "the moved page stays selected"); } +/// A page drag with the copy modifier held — Ctrl (Option on macOS) — instead of none. +/// Modifiers are input state: `ModifiersChanged` once, as the column-select drag does. +fn copy_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) { + page_drag(h, from, to, copy_modifiers(h)); +} + +/// The copy modifier of the OS the app reports: Option on macOS, Ctrl elsewhere. That is the OS +/// the gesture itself reads, so a Mac browser — a wasm build, where `cfg!(target_os = "macos")` is +/// false — is covered by asking the context rather than the target. +fn copy_modifiers(h: &Harness<'static, PdfCraftApp>) -> Modifiers { + if h.ctx.os() == egui::os::OperatingSystem::Mac { Modifiers::ALT } else { Modifiers::CTRL } +} + +/// Drag the page under `from` to `to` with `modifiers` held for the whole gesture. +fn page_drag(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + for k in 1..=5 { + h.hover_at(from + (to - from) * (k as f32 / 5.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +#[test] +fn dragging_with_the_copy_modifier_copies_the_pages_into_the_gap() { + let mut h = organize(3); + let before = page_texts(h.state()); + let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect(); + let (from, to) = (grab(&h, "Page 1").center(), grab(&h, "Page 3").right_center() - egui::vec2(10.0, 0.0)); + copy_drag(&mut h, from, to); + // The original stays first; a copy of page 1 lands after page 3 (#2997). + let after = page_texts(h.state()); + assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "{after:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page")); + let sel: Vec = h.state().views[0].selected.iter().copied().collect(); + assert_eq!(sel, vec![3], "the copy is selected"); +} + +/// macOS is not only `cfg!(target_os = "macos")`: the wasm build runs in a Mac browser, where the +/// cfg is false but the context says Mac. The gesture must follow the context, or Option-drag +/// there moves the pages instead of copying them (#3092 review). +#[test] +fn option_copies_and_ctrl_moves_when_the_context_reports_macos() { + let grab = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_label(label).rect(); + let points = |h: &Harness<'static, PdfCraftApp>| (grab(h, "Page 1").center(), grab(h, "Page 3").right_center() - egui::vec2(10.0, 0.0)); + + // Ctrl is not the copy modifier on macOS, so that drag moves the page. + let mut h = organize(3); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + let before = page_texts(h.state()); + let (from, to) = points(&h); + page_drag(&mut h, from, to, Modifiers::CTRL); + assert_eq!(page_texts(h.state()), vec![before[1].clone(), before[2].clone(), before[0].clone()], "Ctrl moves on macOS"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move page")); + + // Option is, so the same drag drops a copy at the gap. + let mut h = organize(3); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + let before = page_texts(h.state()); + let (from, to) = points(&h); + page_drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(page_texts(h.state()), vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone()], "Option copies on macOS"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Duplicate page")); + let sel: Vec = h.state().views[0].selected.iter().copied().collect(); + assert_eq!(sel, vec![3], "the copy is selected"); +} + #[test] fn copying_cutting_and_pasting_pages() { let mut h = organize(4); diff --git a/docs/roadmap.md b/docs/roadmap.md index c76936a7ef..9b5100f37e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,7 @@ > **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) > **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 (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; 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 (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -120,6 +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). 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. | @@ -199,6 +201,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 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. | +| 2026-10-17 | minor | M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the dragged pages into the drop gap (a "+" badge says so; the copies land selected, one undo step); `page_duplicate` takes `at`, and the UI control channel's `ui.drag` modifiers reach the input state. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 26f730ede1..e734111283 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2824,8 +2824,8 @@ milestone = "M4" status = "shipped" commands = ["page.duplicate"] tools = ["page_duplicate"] -evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last"] -notes = "copies share fonts and images with the originals" +evidence = ["crates/ui-egui/tests/ui_group/pageboxes.rs::duplicate_pages_copies_the_current_page", "crates/ui-egui/tests/editing.rs::dragging_with_the_copy_modifier_copies_the_pages_into_the_gap", "crates/ui-egui/tests/control.rs::ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page", "crates/organize/src/tests.rs::duplicate_pages_inserts_copies_after_the_last", "crates/organize/src/tests.rs::duplicate_pages_at_inserts_the_copies_there", "crates/automation/tests/automation.rs::page_duplicate_at_inserts_the_copies_there"] +notes = "copies share fonts and images with the originals; Ctrl-drag (Option on macOS) in the page grid drops the copies at the gap instead of moving the pages, with a \"+\" badge and the copies selected (#2997); page_duplicate takes `at` for where the first copy goes (left out: after the last of them)" [[feature]] id = "organize.reverse-pages"