Browse / github.com/storytold/pdfcraft / patch
M7: arrange added text and images in the drawing order (#3798)
[Feature Request] Image layer ordering (bring to front / send to back) for selected images ### Feature Request **Image layer ordering (bring to front / send to back) for selected images in "Edit text and images"** When several images overlap on a page, the one drawn later in the content stream covers the earlier one. Acrobat Pro, PDF Studio and similar editors let you change that order: right-click a selected image → **Arrange → Bring to Front / Bring Forward / Send Backward / Send to Back**, with shortcuts (PDF Studio: `Ctrl+Shift+↑` for front, `Ctrl+↑` for forward, and the mirrored pair for back). PdfCraft can select, move, resize, rotate, flip and crop an image, but there is n
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- AdrianTM (imported from pull request #4914, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +466 −7 in 20 files
- Review
- pending
- Shared
- 2026-10-28
- Id
88c0349d15b7b703ea726ef1
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/automation/src/content.rs crates/automation/src/dispatch.rs crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/edit/src/added.rs crates/edit/src/added/store.rs crates/edit/src/tests.rs crates/engine/src/edit.rs crates/engine/src/edit/labels.rs crates/engine/src/edit/run.rs crates/engine/src/history.rs crates/engine/src/lib.rs crates/engine/src/session/editing.rs crates/engine/src/tests.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/editing.rs crates/ui-egui/src/tools/content_ui.rs crates/ui-egui/tests/it/content.rs parity/acrobat-features.toml
@@ -745,3 +745,5 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M7, #3798):** Added text and images can be arranged in the drawing order: Ctrl/Cmd+Up and Down (with Shift to the front or back) in Edit a PDF, and `content_arrange` (`Edit::ArrangeContent`). The items trade places among themselves, one undo step each, saved in the file; the page's own content stays put, and there is no context-menu entry yet.+@@ -208,6 +208,23 @@ impl Automation { self.apply(a, Edit::UpdateContent { page, index, content }) } + /// Move an added item in the drawing order of the page's added items. The result carries the+ /// item's new 1-based index.+ pub(crate) fn content_arrange(&mut self, a: &Args) -> Result<Value> {+ let (page, index, _, _) = self.item(a)?;+ let to = match a.str("to")? {+ "front" => pdfcraft_engine::ArrangeContent::Front,+ "forward" => pdfcraft_engine::ArrangeContent::Forward,+ "backward" => pdfcraft_engine::ArrangeContent::Backward,+ "back" => pdfcraft_engine::ArrangeContent::Back,+ other => return Err(bad(format!("unknown to {other:?} (front, forward, backward, back)"))),+ };+ let count = self.doc(a)?.added().iter().filter(|x| x.page == page).count();+ let mut result = self.apply(a, Edit::ArrangeContent { page, index, to })?;+ result["index"] = json!(to.target(index, count) + 1);+ Ok(result)+ }+ pub(crate) fn content_delete(&mut self, a: &Args) -> Result<Value> { let (page, index, _, _) = self.item(a)?; self.apply(a, Edit::DeleteContent { page, index })@@ -649,6 +649,7 @@ impl Automation { "page_add_image" => self.page_add_image(&a)?, "content_update" => self.content_update(&a)?, "content_delete" => self.content_delete(&a)?,+ "content_arrange" => self.content_arrange(&a)?, "content_copy" => self.content_copy(&a)?, "doc_print" => self.doc_print(&a)?, "attachment_add" => {@@ -646,6 +646,18 @@ pub fn tools() -> Vec<ToolDef> { }), &["doc", "page", "index"], )),+ t(+ "content_arrange",+ "Arrange added content",+ "Move an added item (page, index from content_list) in the drawing order of the page's added items: to front (over every other added item), forward (over the next one), backward (under the previous one) or back (under all of them). The page's own content stays where it is. The result's index is the item's new number, and content_list follows the new order. An item already at that end is an error. Undoable.",+ )+ .with(schema(+ json!({+ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },+ "to": { "type": "string", "enum": ["front", "forward", "backward", "back"] },+ }),+ &["doc", "page", "index", "to"],+ )), t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),@@ -4459,6 +4459,47 @@ fn printing_through_tools() { assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } +/// #3798: content_arrange moves an added item in the drawing order; content_list follows it and+/// the result names the new index; the end of the order is an error and changes nothing.+#[test]+fn content_arrange_reorders_added_items() {+ let dir = workdir("content-arrange");+ let mut a = auto(&dir);+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();+ for (i, word) in ["ONE", "TWO", "THREE"].into_iter().enumerate() {+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": word, "at": [20, 20 + 30 * i] }));+ }+ let order = |a: &mut Automation| -> Vec<String> {+ ok(a, "content_list", json!({ "doc": doc }))["items"].as_array().unwrap().iter().map(|i| i["text"].as_str().unwrap().to_string()).collect()+ };+ assert_eq!(order(&mut a), ["ONE", "TWO", "THREE"]);+ let r = ok(&mut a, "content_arrange", json!({ "doc": doc, "page": 1, "index": 1, "to": "front" }));+ assert_eq!(r["index"], 3);+ assert_eq!(order(&mut a), ["TWO", "THREE", "ONE"]);+ let r = ok(&mut a, "content_arrange", json!({ "doc": doc, "page": 1, "index": 3, "to": "backward" }));+ assert_eq!(r["index"], 2);+ assert_eq!(order(&mut a), ["TWO", "ONE", "THREE"]);+ let r = ok(&mut a, "content_arrange", json!({ "doc": doc, "page": 1, "index": 3, "to": "back" }));+ assert_eq!(r["index"], 1);+ assert_eq!(order(&mut a), ["THREE", "TWO", "ONE"]);+ let r = ok(&mut a, "content_arrange", json!({ "doc": doc, "page": 1, "index": 1, "to": "forward" }));+ assert_eq!(r["index"], 2);+ assert_eq!(order(&mut a), ["TWO", "THREE", "ONE"]);+ // One undo step, and it puts the order back.+ ok(&mut a, "edit_undo", json!({ "doc": doc }));+ assert_eq!(order(&mut a), ["THREE", "TWO", "ONE"]);+ ok(&mut a, "edit_redo", json!({ "doc": doc }));+ // The end of the order, a missing item and a bad direction are refused and change nothing.+ for args in [json!({ "index": 3, "to": "front" }), json!({ "index": 1, "to": "back" }), json!({ "index": 9, "to": "front" })] {+ let mut call = args.clone();+ call["doc"] = json!(doc);+ call["page"] = json!(1);+ assert!(matches!(a.call("content_arrange", &call), Err(ToolError::Failed(_))), "{args}");+ }+ assert!(matches!(a.call("content_arrange", &json!({ "doc": doc, "page": 1, "index": 1, "to": "up" })), Err(ToolError::InvalidArgs(_))));+ assert_eq!(order(&mut a), ["TWO", "THREE", "ONE"]);+}+ #[test] fn adding_content_through_tools() { let dir = workdir("content");@@ -30,7 +30,7 @@ use layout::Piece; #[cfg(test)] pub(crate) use layout::WRAP_CALLS; pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};-pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};+pub use store::{Arrange, add_content, arrange_content, delete_content, isolate_content, list_added, scan_markers, update_content}; pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan}; #[derive(Clone, Debug, PartialEq)]@@ -242,6 +242,69 @@ pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content Ok(()) } +/// Where to move an added item in the page's drawing order, among the items PdfCraft added there+/// (the page's own content stays where it is).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Arrange {+ /// Over every other added item.+ Front,+ /// Over the next added item.+ Forward,+ /// Under the previous added item.+ Backward,+ /// Under every other added item.+ Back,+}++impl Arrange {+ /// Where item `index` of `count` ends up (`count` at least 1; an index past the end counts as the last).+ pub fn target(self, index: usize, count: usize) -> usize {+ let last = count.saturating_sub(1);+ match self {+ Arrange::Front => last,+ Arrange::Forward => index.saturating_add(1).min(last),+ Arrange::Backward => index.min(last).saturating_sub(1),+ Arrange::Back => 0,+ }+ }+}++/// Move item `index` of `page` in the drawing order of the page's added items. Returns its new+/// index. An item already as far as it can go is an error, so nothing is recorded for it.+///+/// Every added item is its own content stream and draws from the initial graphics state, so items+/// trade places freely: only the streams the items sit in are rearranged, and whatever else the+/// page's contents hold stays put.+pub fn arrange_content(doc: &mut Document, page: usize, index: usize, to: Arrange) -> Result<usize, EditError> {+ let items: Vec<ObjRef> = list_added(doc).into_iter().filter(|a| a.page == page).map(|a| a.obj).collect();+ let item = *items+ .get(index)+ .ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page.saturating_add(1), index.saturating_add(1))))?;+ let target = to.target(index, items.len());+ if target == index {+ let side = if matches!(to, Arrange::Front | Arrange::Forward) { "front" } else { "back" };+ return Err(EditError::Invalid(format!("the item is already at the {side}")));+ }+ let p = page_list(doc).swap_remove(page);+ let mut list = contents(doc, &p)?;+ // The places in the page's contents the items occupy, in drawing order: items trade these.+ // Counted entry by entry, so a stream the contents name twice holds two places, not one.+ let slots: Vec<usize> = list.iter().enumerate().filter(|(_, e)| e.as_ref().is_some_and(|r| items.contains(&r))).map(|(i, _)| i).collect();+ if slots.len() != items.len() {+ return Err(EditError::Invalid("the page's added items can't be found in its contents".into()));+ }+ let mut order = items;+ order.remove(index);+ order.insert(target, item);+ for (slot, obj) in slots.into_iter().zip(order) {+ if let Some(entry) = list.get_mut(slot) {+ *entry = Object::Ref(obj);+ }+ }+ doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;+ Ok(target)+}+ /// Remove item `index` of `page`. pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { let a = find(doc, page, index)?;@@ -957,6 +957,110 @@ fn combined_marker_scan_matches_the_independent_scans() { assert_eq!(text.size, 24.0); } +/// #3798: added items trade places in the drawing order, whatever else the page holds; an item+/// already at the end it is sent to is an error and changes nothing.+#[test]+fn added_items_are_arranged_in_the_drawing_order() {+ use crate::added::{Arrange, arrange_content};+ let mut doc = fixture();+ let page_before = streams(&doc, 0)[0].clone();+ for word in ["AAA", "BBB", "CCC"] {+ let t = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: word.into(), ..AddedText::default() };+ add_content(&mut doc, 0, &Content::Text(t)).unwrap();+ }+ let texts = |doc: &Document| -> Vec<&'static str> {+ list_added(doc)+ .iter()+ .filter(|a| a.page == 0)+ .map(|a| match &a.content {+ Content::Text(t) => ["AAA", "BBB", "CCC"].into_iter().find(|w| *w == t.text).unwrap_or("?"),+ Content::Image(_) => "image",+ })+ .collect()+ };+ // The order they are drawn in, read off the page's streams.+ let drawn = |doc: &Document| -> Vec<&'static str> {+ let all = streams(doc, 0).join("\n");+ let mut at: Vec<(usize, &'static str)> =+ ["AAA", "BBB", "CCC"].into_iter().filter_map(|w| all.find(&format!("({w})")).map(|i| (i, w))).collect();+ at.sort();+ at.into_iter().map(|(_, w)| w).collect()+ };+ assert_eq!(texts(&doc), ["AAA", "BBB", "CCC"]);+ assert_eq!(drawn(&doc), ["AAA", "BBB", "CCC"]);++ assert_eq!(arrange_content(&mut doc, 0, 0, Arrange::Front).unwrap(), 2);+ assert_eq!((texts(&doc), drawn(&doc)), (vec!["BBB", "CCC", "AAA"], vec!["BBB", "CCC", "AAA"]));+ assert_eq!(arrange_content(&mut doc, 0, 2, Arrange::Backward).unwrap(), 1);+ assert_eq!(drawn(&doc), ["BBB", "AAA", "CCC"]);+ assert_eq!(arrange_content(&mut doc, 0, 1, Arrange::Forward).unwrap(), 2);+ assert_eq!(drawn(&doc), ["BBB", "CCC", "AAA"]);+ assert_eq!(arrange_content(&mut doc, 0, 2, Arrange::Back).unwrap(), 0);+ assert_eq!((texts(&doc), drawn(&doc)), (vec!["AAA", "BBB", "CCC"], vec!["AAA", "BBB", "CCC"]));++ // Already at the end: an error, and the document is as it was.+ let before = streams(&doc, 0);+ for (index, to) in [(0, Arrange::Back), (0, Arrange::Backward), (2, Arrange::Front), (2, Arrange::Forward)] {+ assert!(arrange_content(&mut doc, 0, index, to).is_err(), "{index} {to:?}");+ }+ assert!(arrange_content(&mut doc, 0, 3, Arrange::Front).is_err(), "no such item");+ assert!(arrange_content(&mut doc, 9, 0, Arrange::Front).is_err(), "no such page");+ assert_eq!(streams(&doc, 0), before);++ // The page's own content is untouched, and the order survives save and reopen.+ arrange_content(&mut doc, 0, 0, Arrange::Front).unwrap();+ assert!(streams(&doc, 0).contains(&page_before), "the page's own content stream is untouched");+ let doc = reopen(&doc);+ assert_eq!((texts(&doc), drawn(&doc)), (vec!["BBB", "CCC", "AAA"], vec!["BBB", "CCC", "AAA"]));+ // Another page's items are not mixed in.+ let mut doc = doc;+ add_content(&mut doc, 1, &Content::Text(AddedText { rect: [10.0, 10.0, 100.0, 40.0], text: "DDD".into(), ..AddedText::default() })).unwrap();+ assert!(arrange_content(&mut doc, 1, 0, Arrange::Front).is_err(), "a lone item has nowhere to go");+ arrange_content(&mut doc, 0, 2, Arrange::Back).unwrap();+ assert_eq!(drawn(&doc), ["AAA", "BBB", "CCC"]);+}++/// A stream the page's contents name twice holds two places in the drawing order: arranging+/// swaps the right entries instead of writing the first match twice. Indexes and pages far past+/// the end are errors, not overflows.+#[test]+fn arranging_copes_with_a_repeated_stream_and_huge_indexes() {+ use crate::added::{Arrange, arrange_content};+ let mut doc = fixture();+ for word in ["AAA", "BBB"] {+ let t = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: word.into(), ..AddedText::default() };+ add_content(&mut doc, 0, &Content::Text(t)).unwrap();+ }+ let [a, b] = [list_added(&doc)[0].obj, list_added(&doc)[1].obj];+ let page = pdfcraft_model::pages(&doc)[0].clone();+ let contents = |doc: &Document| -> Vec<Object> {+ let list = pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned().unwrap();+ doc.resolve(&list).as_array().cloned().unwrap()+ };+ // Contents: [..., A, B] becomes [..., A, B, A] by naming A again.+ let mut list = contents(&doc);+ list.push(Object::Ref(a));+ doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap();+ let items: Vec<_> = list_added(&doc).iter().map(|i| i.obj).collect();+ assert_eq!(items, [a, b, a], "the repeated stream is listed at both places");+ let tail = |doc: &Document| -> Vec<Option<_>> { contents(doc).iter().rev().take(3).rev().map(Object::as_ref).collect() };+ assert_eq!(arrange_content(&mut doc, 0, 1, Arrange::Front).unwrap(), 2);+ assert_eq!(tail(&doc), [Some(a), Some(a), Some(b)], "B goes last, both places of A keep A");+ assert_eq!(arrange_content(&mut doc, 0, 2, Arrange::Back).unwrap(), 0);+ assert_eq!(tail(&doc), [Some(b), Some(a), Some(a)]);++ // Far past the end: errors, whatever the arithmetic would do.+ for (page, index) in [(usize::MAX, 0), (0, usize::MAX), (usize::MAX, usize::MAX)] {+ for to in [Arrange::Front, Arrange::Forward, Arrange::Backward, Arrange::Back] {+ assert!(arrange_content(&mut doc, page, index, to).is_err(), "{page} {index} {to:?}");+ }+ }+ assert_eq!(Arrange::Forward.target(usize::MAX, 3), 2);+ assert_eq!(Arrange::Backward.target(usize::MAX, 3), 1);+ assert_eq!(Arrange::Back.target(usize::MAX, 0), 0);+ assert_eq!(Arrange::Front.target(0, 0), 0);+}+ #[test] fn added_text_and_images_are_page_content_that_stays_editable() { let mut doc = fixture();@@ -466,6 +466,12 @@ pub enum Edit { page: usize, index: usize, },+ /// Move an added item in the drawing order of the page's added items (`index` among them).+ ArrangeContent {+ page: usize,+ index: usize,+ to: pdfcraft_edit::added::Arrange,+ }, /// Replace an added image with another file (keeping its box and transforms). ReplaceImage { page: usize,@@ -127,6 +127,10 @@ pub const EDIT_LABELS: &[&str] = &[ "Add image", "Edit content", "Delete content",+ "Bring to front",+ "Bring forward",+ "Send backward",+ "Send to back", "Paste", "Apply redactions", "Remove redaction marks",@@ -271,6 +275,12 @@ impl Edit { Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), Edit::DeleteContent { .. } => Label::of("Delete content"),+ Edit::ArrangeContent { to, .. } => Label::of(match to {+ pdfcraft_edit::added::Arrange::Front => "Bring to front",+ pdfcraft_edit::added::Arrange::Forward => "Bring forward",+ pdfcraft_edit::added::Arrange::Backward => "Send backward",+ pdfcraft_edit::added::Arrange::Back => "Send to back",+ }), Edit::ReplaceImage { .. } => Label::of("Replace image"), Edit::PasteContent { .. } => Label::of("Paste"), Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),@@ -89,6 +89,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::UpdateContent { .. } | Edit::MoveObjects { .. } | Edit::DeleteContent { .. }+ | Edit::ArrangeContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } | Edit::ClearRedactions@@ -561,6 +562,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?,+ Edit::ArrangeContent { page, index, to } => drop(pdfcraft_edit::added::arrange_content(doc, *page, *index, *to)?), Edit::ReplaceImage { page, index, name, bytes } => { let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {@@ -81,6 +81,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. }+ | Edit::ArrangeContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } | Edit::AddHeaderFooter { .. }@@ -78,8 +78,8 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{- CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,- text_width as added_text_width,+ Arrange as ArrangeContent, CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect,+ text_rect as added_text_rect, text_width as added_text_width, }; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,@@ -395,6 +395,7 @@ impl Session { | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. }+ | Edit::ArrangeContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))@@ -3476,6 +3476,53 @@ fn added_text_and_images_render_and_stay_editable() { assert_eq!(s.get(id).unwrap().added().len(), 2); } +/// Arrange added content (#3798): one undoable step per move, named by the direction, saved in+/// the file, and an item already at the end is refused without a history step.+#[test]+fn arranging_added_content_is_one_undoable_step_that_is_saved() {+ let (mut s, id) = session_with(1);+ for word in ["ONE", "TWO", "THREE"] {+ let t = AddedText { rect: [20.0, 250.0, 180.0, 264.4], text: word.into(), ..AddedText::default() };+ s.apply(id, Edit::AddText { page: 0, text: t }).unwrap();+ }+ let order = |s: &Session| order_of(s, id);+ s.apply(id, Edit::ArrangeContent { page: 0, index: 0, to: ArrangeContent::Front }).unwrap();+ assert_eq!(order(&s), ["TWO", "THREE", "ONE"]);+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Bring to front"));+ let bytes = s.save_bytes(id).unwrap();+ let mut other = Session::new();+ let reopened = other.open("again.pdf", None, bytes, None).unwrap();+ assert_eq!(order_of(&other, reopened), ["TWO", "THREE", "ONE"], "the order is in the saved file");+ s.undo(id).unwrap();+ assert_eq!(order(&s), ["ONE", "TWO", "THREE"]);+ s.redo(id).unwrap();+ assert_eq!(order(&s), ["TWO", "THREE", "ONE"]);+ for (to, label) in+ [(ArrangeContent::Back, "Send to back"), (ArrangeContent::Forward, "Bring forward"), (ArrangeContent::Backward, "Send backward")]+ {+ s.apply(id, Edit::ArrangeContent { page: 0, index: 1, to }).unwrap();+ assert_eq!(s.get(id).unwrap().can_undo(), Some(label));+ }+ // At the end already: refused, and no step is recorded for it.+ let steps = s.get(id).unwrap().can_undo().map(str::to_string);+ let first = order(&s);+ assert!(s.apply(id, Edit::ArrangeContent { page: 0, index: 2, to: ArrangeContent::Front }).is_err());+ assert_eq!(order(&s), first);+ assert_eq!(s.get(id).unwrap().can_undo().map(str::to_string), steps);+}++fn order_of(s: &Session, id: DocId) -> Vec<String> {+ s.get(id)+ .unwrap()+ .added()+ .iter()+ .map(|a| match &a.content {+ AddedContent::Text(t) => t.text.clone(),+ AddedContent::Image(_) => "image".into(),+ })+ .collect()+}+ /// Edit ▸ Copy and Paste of added content (#2636): a text box comes back with all its style, an /// image with its picture and transforms, in the same document and in another. #[test]@@ -2464,7 +2464,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::prepare::keys(ui.ctx(), view); } if editing_content {- crate::content_ui::keys(ui.ctx(), view);+ crate::content_ui::keys(ui.ctx(), view, &added, unobstructed); } if tool == QuickTool::Link { crate::link_ui::keys(ui.ctx(), view);@@ -218,11 +218,22 @@ impl PdfCraftApp { fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return };+ // The selected added item, by its place in the page's content: the undo may reorder them.+ let selected = self.views[i].content.selected.and_then(|(page, index)| {+ let doc = self.session.get(id)?;+ let item = doc.added().iter().filter(|a| a.page == page).nth(index)?;+ Some((page, item.obj))+ }); let result = if undo { self.session.undo(id) } else { self.session.redo(id) }; match result { Ok(label) => { if let Some(doc) = self.session.get(id) { self.views[i].document_changed(&doc.info);+ // It stays selected where it now is, or not at all if it is gone.+ if let Some((page, obj)) = selected {+ self.views[i].content.selected =+ doc.added().iter().filter(|a| a.page == page).position(|a| a.obj == obj).map(|index| (page, index));+ } } self.views[i].comments.selected = None; let label = crate::i18n::edit_label(&label);@@ -642,6 +653,7 @@ fn content_page(edit: &Edit) -> Option<usize> { | Edit::AddImage { page, .. } | Edit::UpdateContent { page, .. } | Edit::DeleteContent { page, .. }+ | Edit::ArrangeContent { page, .. } | Edit::ReplaceImage { page, .. } => Some(*page), _ => None, }@@ -4,7 +4,7 @@ //! Delete to remove it. Each change is one undoable engine edit. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};-use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};+use pdfcraft_engine::{Added, AddedContent, AddedText, ArrangeContent, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform};@@ -512,11 +512,43 @@ pub(crate) fn paint_page( } } -/// Delete removes the selected item; Escape clears the selection.-pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {+/// Delete removes the selected item; Escape clears the selection. Ctrl/Cmd+Up and Down move it one+/// step over or under the next added item, and with Shift all the way to the front or the back+/// (#3798).+pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, added: &[Added], unobstructed: bool) { if view.content.selected.is_none() || view.content.draft.is_some() || ctx.egui_wants_keyboard_input() { return; }+ // Not under a dialog or the palette: the shortcut is left alone there.+ let to = unobstructed+ .then(|| {+ ctx.input_mut(|i| {+ use egui::{Key, KeyboardShortcut as Shortcut, Modifiers};+ let both = Modifiers::COMMAND | Modifiers::SHIFT;+ // With Shift first: a shortcut without it would match these too.+ [+ (Shortcut::new(both, Key::ArrowUp), ArrangeContent::Front),+ (Shortcut::new(both, Key::ArrowDown), ArrangeContent::Back),+ (Shortcut::new(Modifiers::COMMAND, Key::ArrowUp), ArrangeContent::Forward),+ (Shortcut::new(Modifiers::COMMAND, Key::ArrowDown), ArrangeContent::Backward),+ ]+ .into_iter()+ .find_map(|(shortcut, to)| i.consume_shortcut(&shortcut).then_some(to))+ })+ })+ .flatten();+ if let Some(to) = to+ && let Some((page, index)) = view.content.selected+ {+ let target = to.target(index, on_page(added, page).len());+ // An item already at that end has nowhere to go: no edit, no history step.+ if target != index {+ view.pending_edit = Some(Edit::ArrangeContent { page, index, to });+ // The item keeps the selection where it lands.+ view.content.selected = Some((page, target));+ }+ return;+ } let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); if del && let Some((page, index)) = view.content.selected.take() { view.pending_edit = Some(Edit::DeleteContent { page, index });@@ -811,6 +811,102 @@ fn format_text_sets_character_spacing_for_added_text() { assert_eq!(h.state().text_style.char_spacing, 2.5); } +/// #3798: Ctrl+Up and Ctrl+Down move the selected added item over or under its neighbour, with+/// Shift to the very front or back, as one undo step each; the item stays selected, and at either+/// end the key does nothing.+#[test]+fn arrange_keys_move_the_selected_item_in_the_drawing_order() {+ let mut h = harness();+ for (i, word) in ["ONE", "TWO", "THREE"].into_iter().enumerate() {+ let text = pdfcraft_engine::AddedText {+ rect: [20.0, 300.0 - 40.0 * i as f64, 180.0, 314.0 - 40.0 * i as f64],+ text: word.into(),+ ..Default::default()+ };+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));+ }+ assert!(h.state_mut().execute("edit.text"));+ crate::support::settle(&mut h);+ let order = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> {+ added(h)+ .iter()+ .map(|a| match &a.content {+ pdfcraft_engine::AddedContent::Text(t) => t.text.clone(),+ pdfcraft_engine::AddedContent::Image(_) => "image".into(),+ })+ .collect()+ };+ let press = |h: &mut Harness<'static, PdfCraftApp>, shift: bool, key: egui::Key| {+ let modifiers = if shift { egui::Modifiers::COMMAND | egui::Modifiers::SHIFT } else { egui::Modifiers::COMMAND };+ h.key_press_modifiers(modifiers, key);+ h.run_steps(3);+ };+ h.state_mut().views[0].content.selected = Some((0, 0));+ h.run_steps(2);+ press(&mut h, false, egui::Key::ArrowUp);+ assert_eq!(order(&h), ["TWO", "ONE", "THREE"], "one step forward");+ assert_eq!(h.state().views[0].content.selected, Some((0, 1)), "ONE is still the selected one");+ press(&mut h, true, egui::Key::ArrowUp);+ assert_eq!(order(&h), ["TWO", "THREE", "ONE"], "to the front");+ assert_eq!(h.state().views[0].content.selected, Some((0, 2)));+ let steps = h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_string);+ press(&mut h, false, egui::Key::ArrowUp);+ assert_eq!(order(&h), ["TWO", "THREE", "ONE"], "already in front: nothing happens");+ assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_string), steps, "and no undo step is made");+ press(&mut h, false, egui::Key::ArrowDown);+ assert_eq!(order(&h), ["TWO", "ONE", "THREE"], "one step backward");+ press(&mut h, true, egui::Key::ArrowDown);+ assert_eq!(order(&h), ["ONE", "TWO", "THREE"], "to the back");+ assert_eq!(h.state().views[0].content.selected, Some((0, 0)));+ // One undo step puts the last move back.+ assert!(h.state_mut().execute("edit.undo"));+ h.run_steps(3);+ assert_eq!(order(&h), ["TWO", "ONE", "THREE"]);+ assert_eq!(h.state().views[0].content.selected, Some((0, 1)), "ONE is still the selected item after the undo");+ assert!(h.state_mut().execute("edit.redo"));+ h.run_steps(3);+ assert_eq!(order(&h), ["ONE", "TWO", "THREE"]);+ assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "and after the redo");+ // Undoing past the item's own creation leaves nothing selected rather than another item.+ for _ in 0..8 {+ h.state_mut().execute("edit.undo");+ h.run_steps(2);+ }+ assert!(added(&h).is_empty());+ assert_eq!(h.state().views[0].content.selected, None);+}++/// #3798: the arrange keys leave a dialog's keyboard alone.+#[test]+fn arrange_keys_do_nothing_under_a_dialog() {+ let mut h = harness();+ for (i, word) in ["ONE", "TWO"].into_iter().enumerate() {+ let text = pdfcraft_engine::AddedText {+ rect: [20.0, 300.0 - 40.0 * i as f64, 180.0, 314.0 - 40.0 * i as f64],+ text: word.into(),+ ..Default::default()+ };+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));+ }+ assert!(h.state_mut().execute("edit.text"));+ crate::support::settle(&mut h);+ h.state_mut().views[0].content.selected = Some((0, 0));+ h.state_mut().set_option("dialog", "about").unwrap();+ h.run_steps(4);+ assert!(h.state().dialog.is_some());+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::ArrowUp);+ h.run_steps(3);+ let texts: Vec<String> = added(&h)+ .iter()+ .map(|a| match &a.content {+ pdfcraft_engine::AddedContent::Text(t) => t.text.clone(),+ pdfcraft_engine::AddedContent::Image(_) => "image".into(),+ })+ .collect();+ assert_eq!(texts, ["ONE", "TWO"], "the shortcut did not reach the page under the dialog");+ assert_eq!(h.state().views[0].content.selected, Some((0, 0)));+}+ /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")@@ -2025,6 +2025,18 @@ tools = ["page_add_text", "content_update"] evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +[[feature]]+id = "edit.arrange-added"+title = "Arrange added content (bring to front, forward, backward, send to back)"+area = "C"+tier = "P2"+milestone = "M7"+status = "shipped"+commands = []+tools = ["content_arrange"]+evidence = ["crates/edit/src/tests.rs::added_items_are_arranged_in_the_drawing_order", "crates/engine/src/tests.rs::arranging_added_content_is_one_undoable_step_that_is_saved", "crates/automation/tests/it/automation.rs::content_arrange_reorders_added_items", "crates/ui-egui/tests/it/content.rs::arrange_keys_move_the_selected_item_in_the_drawing_order"]+notes = "An added text box or image moves in the drawing order of the page's added items (#3798): Ctrl/Cmd+Up and Down one step, with Shift to the very front or back, or `content_arrange` (to: front, forward, backward, back; the result gives the new index). One undo step each, saved in the file (the items' streams trade places in the page contents); an item already at that end is refused with no history step. Missing: no context-menu Arrange entry; only items PdfCraft added take part, so the page's own content and images, and the comment and form layers, stay where they are, and an item cannot be sent behind them (a watermark under a figure still needs Backgrounds or Watermark); several selected items are not moved together."+ [[feature]] id = "edit.find-replace" title = "Find and replace in edit mode"