M4: Ctrl-drag copies pages in the Organize grid (#2997) (#3092)

* M4: Ctrl-drag copies pages in the Organize grid (#2997)

Dragging thumbnails in the Organize page grid with Ctrl held (Option on
macOS) drops copies at the gap instead of moving the pages: a "+" badge
by the pointer, the copies land at the gap selected, and one undo step
takes them out. The insertion point joins the engine edit
(Edit::DuplicatePages { at }) so the copy is one undo step rather than a
duplicate plus a move.

page_duplicate takes a 1-based "at" (the first copy becomes page "at"),
so agents can drive the same thing, and the UI control channel's
ui.drag modifiers now reach the input state -- which gestures that read
it (this copy-drag, and Alt-drag column select) need.

Closes #2997.

* fix(ui): take the grid's copy modifier from the runtime OS (#3092 review)

Option-drag in the Organize grid moved the pages instead of copying them in a Mac
browser: `cfg!(target_os = "macos")` is false in the wasm build, and eframe sets the
context's OS from the browser. `copy_modifier` now takes the context and asks
`commands::mac_shortcuts`, as the shortcut labels do.

The drag helpers in `tests/editing.rs` and `tests/control.rs` picked the modifier the
same compile-time way, so they would have sent Ctrl on a Mac host; both read the
context's OS now. The new regression test
`option_copies_and_ctrl_moves_when_the_context_reports_macos` drives a Mac context —
Ctrl moves, Option copies — and fails without this change.
This commit is contained in:
Mawfyy
2026-10-18 11:13:22 -06:00
committed by GitHub
parent 98cbd38a8e
commit 932bf9c6bf
16 changed files with 240 additions and 29 deletions
+3
View File
@@ -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.
+9 -3
View File
@@ -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<usize> {
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<Option<usize>> {
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> {
+2 -2
View File
@@ -825,9 +825,9 @@ pub fn tools() -> Vec<ToolDef> {
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",
+13
View File
@@ -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");
+5 -3
View File
@@ -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<usize>,
at: Option<usize>,
},
/// 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)?;
+2 -2
View File
@@ -870,7 +870,7 @@ fn form_with_field(name: &str) -> Vec<u8> {
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::<Vec<_>>());
@@ -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"));
}
+4 -3
View File
@@ -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<usize>) -> 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
+15 -2
View File
@@ -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");
}
+35 -8
View File
@@ -4862,6 +4862,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
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();
}
}
}
}
+1 -1
View File
@@ -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" => {
+14 -3
View File
@@ -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<egui::Event>| {
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]] })))
}
+5
View File
@@ -133,6 +133,11 @@ impl PdfCraftApp {
let to = to.min(info.pages.len() - n);
view.select_pages(&(to..to + n).collect::<Vec<_>>());
}
Edit::DuplicatePages { pages, at: Some(at) } => {
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
let at = at.min(info.pages.len().saturating_sub(n));
view.select_pages(&(at..at + n).collect::<Vec<_>>());
}
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
+52
View File
@@ -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<String> {
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<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (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();
+75
View File
@@ -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<usize> = 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<usize> = 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);
+3
View File
@@ -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<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
@@ -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. |
+2 -2
View File
@@ -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"