diff --git a/ROADMAP.md b/ROADMAP.md index 3eb8e6aaa..907beb98a 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,6 +177,8 @@ 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-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 c01187306..0e2e0dd69 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)?, @@ -1793,12 +1794,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 69d1eda21..53a5ac57a 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 6a348e0d8..f3bcfd21a 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 bf689af54..8f39248ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1281,9 +1281,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. @@ -1948,7 +1950,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"), @@ -2335,7 +2337,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 d5ffac500..0f012d9e0 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 e120d486b..99c228c60 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 32a386d22..9bd3eb396 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 00ff0eb3a..b5ca6abab 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 832ce7cfb..0d230982e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -627,7 +627,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 630d66190..a4ab35d96 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 4d1f723c8..e554d7a1b 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 88ed1c0a9..e6af166d7 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 680d49a77..2ef52a440 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 c76936a7e..9b5100f37 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 d8fbe77dd..547c5561c 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"