From 82b1093b7354aa44e5bfb5c36bd24bee3410d999 Mon Sep 17 00:00:00 2001 From: Tevinhead Date: Sun, 11 Oct 2026 04:38:34 +0700 Subject: [PATCH] M7: select and move mixed PDF objects together (#844) (#907) Preserve source glyphs and artwork, resolve the group atomically, and expose generation-scoped automation tools with selection and rendering regressions. Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 32 +- crates/automation/README.md | 8 + crates/automation/src/content.rs | 68 ++++ crates/automation/src/lib.rs | 2 + crates/automation/src/tools.rs | 8 + crates/automation/tests/automation.rs | 56 ++++ crates/edit/src/images.rs | 70 +++++ crates/edit/src/lib.rs | 3 + crates/edit/src/objects.rs | 338 ++++++++++++++++++++ crates/edit/src/tests.rs | 382 ++++++++++++++++++++++ crates/edit/src/text.rs | 179 ++++++++++- crates/engine/src/lib.rs | 38 +++ crates/engine/src/tests.rs | 137 ++++++++ crates/ui-egui/src/canvas.rs | 16 +- crates/ui-egui/src/content_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 10 +- crates/ui-egui/src/editing.rs | 3 +- crates/ui-egui/src/i18n/ar.tsv | 13 + crates/ui-egui/src/i18n/bg.tsv | 13 + crates/ui-egui/src/i18n/cs.tsv | 12 + crates/ui-egui/src/i18n/de.tsv | 13 + crates/ui-egui/src/i18n/es.tsv | 13 + crates/ui-egui/src/i18n/fr.tsv | 13 + crates/ui-egui/src/i18n/hu.tsv | 13 + crates/ui-egui/src/i18n/it.tsv | 13 + crates/ui-egui/src/i18n/ja.tsv | 13 + crates/ui-egui/src/i18n/pl.tsv | 13 + crates/ui-egui/src/i18n/pt-br.tsv | 13 + crates/ui-egui/src/i18n/ru.tsv | 13 + crates/ui-egui/src/i18n/te.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 13 + crates/ui-egui/src/i18n/zh-hans.tsv | 13 + crates/ui-egui/src/i18n/zh-hant.tsv | 13 + crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/object_ui.rs | 363 +++++++++++++++++++++ crates/ui-egui/src/panels.rs | 10 + crates/ui-egui/tests/object_selection.rs | 383 +++++++++++++++++++++++ docs/editing-multiple-objects.md | 87 +++++ parity/acrobat-features.toml | 10 +- 39 files changed, 2386 insertions(+), 31 deletions(-) create mode 100644 crates/edit/src/objects.rs create mode 100644 crates/ui-egui/src/object_ui.rs create mode 100644 crates/ui-egui/tests/object_selection.rs create mode 100644 docs/editing-multiple-objects.md diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index e28d15275..c88956077 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6f22177566d03164cba095ffce12bd84e45e585bdb6644294eb73f565bbfd8cc" +sha256 = "b568edd80dfce809d1e8fbdad9f2c30e1fabc04d631a06dc154fd8e799ede3e0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50b667b08816c04b3d14c39df2f168784ce3b22ebd9694d08a2184b3538edc33" +sha256 = "cbaf398cd35b4667ceffe4f93df014289da9ea5711eb936f0315b6029bf93681" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "933a864a7b0f6380dc1f9f636340f586638da636e660af6553d12c44981b7f94" +sha256 = "8e38ce2add4bc43b972b83d78046fe0fe1c9d2eb85d0092a73867eea86ad2691" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5afaae7ed3c8a0b032878d78fbd76a03fb1591e0d29f9ae5a6657afaa00706a6" +sha256 = "0c9fc284430ebf75561ec92783b63f5a9cc5d8eaa077e9b31c65d429eb40b69f" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "45acfa2a5b71e7e7da7e1b975a712e610196f8eeb518c177751d378eef789b17" +sha256 = "9e15919fd57382776ffd2a09d0d2985ceeba526a3ef28fd58568c32aed43fc53" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9d6b99b7b4ed205a39ea918e2694e06ed654d0adeff71016719ce764ca756b65" +sha256 = "df37b79a0c7e3d6ed171264103c90cd3fd48ee34e8a1fe9b1dd132947e709bd1" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "547480a92fff9ef5a980a00ca9ce7f35ee61484be1c42abc7c152632eb9890bc" +sha256 = "3923ec15c37e091793174b3546fce68bd5560cb7206a993d72f82ff878f66c07" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ccf859f5ebf443a92ab906b18e15daec313dbf4789824a7e75087e0e39b99ad5" +sha256 = "cf915289e47679e6a778c7924863cbed25fcf00c1a8190379e1afeb10f411fbd" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4d56d65bd7863441289e77d76e8f95993be5bf0100a06501eaf3daacbc6f2074" +sha256 = "b7ec9e05598242f3b95a5edf4680f4faa437ea099297809a693452e7094d41c8" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "225fe1c83fb8734cd8184cbf389c0e4d94dcb180b23ada50e1da60eb69ceb132" +sha256 = "7052f5ee37d26e0173bc5fe48411c94e1b48e31e0fc8a9188fca2bec54f29e32" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6e401fd4a888c463335c0a89ddd403be7081a0def05c62f34fb58965b71025e0" +sha256 = "9db76f9f7f22a3f93b213f3931ba30036a2a60ac9c0b65c2fddc6c0a6dd4751c" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "58964992dc3487fbe76db5e90b53f8ffd21164f738015e005020c186a356cae3" +sha256 = "846ade5369c59ce68cd3be1722645d82de21131125606f20aeec134b2ef1412e" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d80f6e2dabb1f657a5368447a445eeb47501e1859fe19aca80c638de56f12ed4" +sha256 = "2737dcd01e375b7d207c92473a4adc97eef23ebf1bbc15d69357f89a072a366f" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "72465faa8cf69c3d5c8a8b069b5757116140df69ac7ff5afb9797c1aac9ee8e5" +sha256 = "0313526d0ba0c1b28f9d79bdae6c85279df65fd0beb9a53522e847316225339d" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfbf320deb41dffb2f07537f850627f83c0f213d82cd20cbfd83e7abd462ca28" +sha256 = "25527729c4119d520b5ae1d3201283d8baca17bcc14706a26adf82479a0499af" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "177e42ffd11fc0c7838ae490dbf4145b5a30b565659e5ef752be78700459b190" +sha256 = "7de1152206c40c411002bbb444533dfef3ee2e5418219a6e3059c2a53a4bf7d6" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/README.md b/crates/automation/README.md index 75f9ee21c..02c081149 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -60,6 +60,14 @@ Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, 4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. +## Moving several page objects + +`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items +with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}` +moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes +and fonts. A refusal changes nothing; success is one undo step. See +[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples. + ## Common command tools Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 9a0448856..39cdaf964 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -200,3 +200,71 @@ impl Automation { self.apply(a, Edit::DeleteContent { page, index }) } } + +impl Automation { + /// One mixed inventory, with exact source references and the current document generation. + pub(crate) fn object_list(&self, a: &Args) -> Result { + let page = self.page(a)?; + let doc = self.doc(a)?; + let info = doc.info.pages.get(page).ok_or_else(|| bad("the page no longer exists"))?; + let objects = doc.editable_objects(page).map_err(failed)?; + let items = objects + .iter() + .map(|o| { + let rect = crate::comments::rect_to_view(info, o.rect.map(|v| v as f32)); + json!({"kind":o.target.kind.as_str(),"index":o.target.index.saturating_add(1),"type":o.content_type, + "rect":rect.map(|v|(f64::from(v)*100.0).round()/100.0),"text":o.text}) + }) + .collect::>(); + Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items})) + } + + pub(crate) fn object_move(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + let page = self.page(a)?; + let rows = a + .get("objects") + .and_then(Value::as_array) + .filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS) + .ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?; + let mut targets = Vec::with_capacity(rows.len()); + for row in rows { + let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?; + if row.keys().any(|k| k != "kind" && k != "index") { + return Err(bad("an object reference accepts only kind and index")); + } + let kind = match row.get("kind").and_then(Value::as_str) { + Some("added") => ObjectKind::Added, + Some("text") => ObjectKind::Text, + Some("image") => ObjectKind::Image, + _ => return Err(bad("object kind must be added, text or image (see object_list)")), + }; + let index = row + .get("index") + .and_then(Value::as_u64) + .and_then(|i| i.checked_sub(1)) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("object index must be a positive integer from object_list"))?; + targets.push(ObjectTarget { kind, index }); + } + let delta = a + .get("offset") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?])) + .filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9)) + .ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?; + let doc = self.doc(a)?; + if let Some(g) = a.get("generation") { + let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?; + if g != doc.edit_generation() { + return Err(failed("the document changed; call object_list again before moving")); + } + } + let offset = doc.object_move_offset(page, delta).map_err(failed)?; + let count = targets.len(); + let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?; + result["moved"] = json!(count); + Ok(result) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index df118d7ae..eec1f4360 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -543,6 +543,8 @@ impl Automation { "link_delete" => self.link_delete(&a)?, "links_from_urls" => self.links_from_urls(&a)?, "links_remove" => self.links_remove(&a)?, + "object_list" => self.object_list(&a)?, + "object_move" => self.object_move(&a)?, "content_list" => self.content_list(&a)?, "page_add_text" => self.page_add_text(&a)?, "page_add_image" => self.page_add_image(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index af0847ebe..f5771d68c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -150,6 +150,14 @@ pub fn tools() -> Vec { t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 } }), &["doc", "page"])), + t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") + .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), + t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, + "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ + "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, + "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, + "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.") .ro() .cmd("edit.column_select") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 9ffb3bbd3..26bc2b26f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3770,6 +3770,62 @@ fn command_batch_refuses_more_than_a_thousand_steps() { assert!(err.to_string().contains("at most 1000 steps"), "{err}"); } +#[test] +fn mixed_objects_move_through_tools_with_generation_and_one_undo() { + let dir = workdir("mixed-object-move"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"before.pdf"})); + let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(inventory["count"], 2); + let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect(); + let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[18,27]})); + assert_eq!(moved["moved"], 2); + let after = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + for (old, new) in inventory["objects"].as_array().unwrap().iter().zip(after["objects"].as_array().unwrap()) { + for i in 0..4 { + assert!((new["rect"][i].as_f64().unwrap() - old["rect"][i].as_f64().unwrap() - if i % 2 == 0 { 18.0 } else { 27.0 }).abs() < 0.02); + } + assert_eq!(old["text"], new["text"]); + } + assert!(a.call("object_move", &json!({"doc":doc,"page":1,"generation":inventory["generation"],"objects":objects,"offset":[1,1]})).is_err()); + let stable = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(stable, after, "a stale generation cannot edit"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], inventory["objects"]); + ok(&mut a, "edit_redo", json!({"doc":doc})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"moved.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"moved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "object_list", json!({"doc":reopened,"page":1}))["objects"], after["objects"]); + assert!(page_text(&mut a, reopened)[0].contains("Added label")); +} + +#[test] +fn object_move_rejects_invalid_nested_references_without_any_change() { + let dir = workdir("object-move-refusals"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + for objects in [ + json!([]), + json!([{"kind":"text","index":0}]), + json!([{"kind":"path","index":1}]), + json!([{"kind":"text","index":1,"extra":true}]), + json!([{"kind":"text","index":1.5}]), + json!([{"kind":"text","index":1},{"kind":"text","index":1}]), + json!([{"kind":"text","index":1},{"kind":"image","index":99}]), + json!(vec![json!({"kind":"text","index":1}); 1001]), + ] { + assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":objects,"offset":[10,20]})).is_err()); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before); + } + for offset in [json!([0, 0]), json!([1]), json!([1, 2, 3]), json!(["1", 2]), json!([1e30, 1])] { + assert!(a.call("object_move", &json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"offset":offset})).is_err()); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), before); + } +} + #[test] fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { let dir = workdir("deleted-image"); diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index d9ba9c96a..c6c0cb5d9 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -304,3 +304,73 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag })?; Ok(()) } + +/// Inventory indexes exclude the streams that belong to added content. +pub(crate) fn selectable_images( + doc: &Document, + page: usize, + excluded: &std::collections::BTreeSet, +) -> Result, EditError> { + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + Ok(page_images(doc, page)? + .into_iter() + .enumerate() + .filter(|(_, image)| !all.get(image.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) + .collect()) +} + +/// Plan all transforms before changing a stream; indexes refer to the original inventory. +/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. +pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { + if indexes.is_empty() { + return Ok(()); + } + let images = page_images(doc, page)?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut plans = std::collections::BTreeMap::new(); + for index in indexes { + let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; + let placement = Matrix(image.draw_matrix); + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?; + let [a, b, c, d, _, _] = inverse.0; + let movement = Matrix([1.0, 0.0, 0.0, 1.0, a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]]); + if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + plans.insert((image.stream, image.op), movement); + } + let mut data = Vec::with_capacity(all.len()); + let mut inside_text = false; + for (stream, (_, bytes)) in all.iter().enumerate() { + let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]); + let ops = pieces.parse().ops; + for (index, op) in ops.iter().enumerate() { + if op.is("BT") { + inside_text = true; + } else if op.is("ET") { + inside_text = false; + } else if inside_text && plans.contains_key(&(stream, index)) { + return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); + } + } + let changed = crate::text::splice(&pieces, &ops, |index| { + let Some(matrix) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; + let original = ops.get(index).cloned().into_iter(); + // Local translations can be tiny under a large CTM. Preserve their precision + // rather than rounding them to zero or amplifying an error on the page. + let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())]; + out.extend(original); + out.push(Op::new("Q", vec![])); + (out, false) + }); + data.push(changed.into_iter().next().unwrap_or_default()); + } + if !plans.is_empty() { + return Err(EditError::Invalid("a selected image operation is missing".into())); + } + let contents = crate::text::rewritten_contents(doc, &all, data); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7c7816d5a..467f5dd2b 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -626,6 +626,9 @@ pub fn marks_present(doc: &Document) -> Vec { found } +pub mod objects; +pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; + mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs new file mode 100644 index 000000000..169d4a2b5 --- /dev/null +++ b/crates/edit/src/objects.rs @@ -0,0 +1,338 @@ +//! Mixed-object inventory and atomic translation in Edit a PDF. Source indexes are resolved +//! together before mutation. Added content appears once, not again as a paragraph/image. + +use std::collections::{BTreeMap, BTreeSet}; + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document, Object}; + +use crate::{Content, EditError}; + +pub const MAX_MOVE_OBJECTS: usize = 1_000; +const MAX_PAGE_OBJECTS: usize = 10_000; +const MAX_COORDINATE: f64 = 1e9; +const MAX_CONTENT_BYTES: usize = 128 * 1024 * 1024; + +/// The source inventory of an editable item. Indexes are zero-based within that inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObjectKind { + Added, + Text, + Image, +} + +impl ObjectKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Text => "text", + Self::Image => "image", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ObjectTarget { + pub kind: ObjectKind, + pub index: usize, +} + +/// An item recognised by Edit a PDF. Geometry is in the page's user space; a Form is one +/// grouped artwork item. Its nested content is kept intact, not selected again separately. +#[derive(Clone, Debug, PartialEq)] +pub struct EditableObject { + pub target: ObjectTarget, + pub rect: [f64; 4], + pub content_type: &'static str, + pub text: Option, +} + +fn valid_rect(rect: [f64; 4]) -> bool { + rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) && rect[2] > rect[0] && rect[3] > rect[1] +} + +pub fn editable_objects(doc: &Document, page: usize) -> Result, EditError> { + checked_streams(doc, page)?; + let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect(); + if added.len() > MAX_PAGE_OBJECTS { + return Err(EditError::Invalid("this page has too many objects to select together".into())); + } + let geometry = added_geometry(doc, page)?.1; + let excluded = added.iter().map(|a| a.obj).collect(); + let mut objects = Vec::new(); + for (index, a) in added.into_iter().enumerate() { + let (content_type, text) = match &a.content { + Content::Text(t) => ("text", Some(t.text.clone())), + Content::Image(_) => ("image", None), + }; + objects.push(EditableObject { + target: ObjectTarget { kind: ObjectKind::Added, index }, + rect: geometry.get(index).ok_or_else(|| EditError::Invalid("an added item has no placement".into()))?.rect, + content_type, + text, + }); + } + for (index, b) in crate::text::selectable_blocks(doc, page, &excluded)? { + objects.push(EditableObject { + target: ObjectTarget { kind: ObjectKind::Text, index }, + rect: b.rect, + content_type: "text", + text: Some(b.text), + }); + } + for (index, image) in crate::images::selectable_images(doc, page, &excluded)? { + objects.push(EditableObject { + target: ObjectTarget { kind: ObjectKind::Image, index }, + rect: image.rect, + content_type: if image.is_form { "form" } else { "image" }, + text: None, + }); + } + if objects.len() > MAX_PAGE_OBJECTS { + return Err(EditError::Invalid("this page has too many objects to select together".into())); + } + if objects.iter().any(|o| !valid_rect(o.rect)) { + return Err(EditError::Invalid("an object has invalid geometry".into())); + } + Ok(objects) +} + +/// Move selected mixed objects by a user-space vector as one atomic operation. All targets +/// refer to the original inventories. A refusal leaves the graph, including overlay, untouched. +pub fn move_objects(doc: &mut Document, page: usize, targets: &[ObjectTarget], offset: [f64; 2]) -> Result<(), EditError> { + if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS { + return Err(EditError::Invalid("select between 1 and 1000 objects to move".into())); + } + if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) || offset == [0.0, 0.0] { + return Err(EditError::Invalid("the move must be finite and non-zero".into())); + } + let inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect(); + let mut unique = BTreeSet::new(); + for target in targets { + if !unique.insert(*target) { + return Err(EditError::Invalid("an object was selected more than once".into())); + } + let object = + inventory.get(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?; + let r = object.rect; + if !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]) { + return Err(EditError::Invalid("the object move is too large".into())); + } + } + let indexes = |kind| targets.iter().filter(|t| t.kind == kind).map(|t| t.index).collect::>(); + let mut work = doc.clone(); + crate::text::translate_blocks(&mut work, page, &indexes(ObjectKind::Text), offset)?; + crate::images::translate_images(&mut work, page, &indexes(ObjectKind::Image), offset)?; + translate_added(&mut work, page, &indexes(ObjectKind::Added), offset)?; + // Placement operators add to size/depth/work budgets, and moving paragraphs can change + // their grouping. Keep the resulting inventory usable; refuse before swapping otherwise. + editable_objects(&work, page)?; + *doc = work; + Ok(()) +} + +struct AddedGeometry { + rect: [f64; 4], + local_rect: [f64; 4], + origin: Matrix, + outside: Matrix, + current: Matrix, + stream: usize, + op: usize, + params: Dict, +} + +/// An added item's metadata is in the coordinate space it was written in, which can differ +/// from today's page rotation/crop/UserUnit. Read its original placement, including graphics +/// state from preceding streams. MoveOrigin retains that basis after a byte-preserving move. +fn added_geometry(doc: &Document, page: usize) -> Result<(Pieces, Vec), EditError> { + let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect(); + if added.is_empty() { + return Ok((Pieces::default(), Vec::new())); + } + let streams = checked_streams(doc, page)?; + let pieces = Pieces::join(&streams.iter().map(|(_, bytes)| bytes.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let owned: BTreeSet<_> = added.iter().map(|a| a.obj).collect(); + let mut geometry = BTreeMap::new(); + let mut first = BTreeMap::new(); + let mut matrix = Matrix::IDENTITY; + let mut stack = Vec::new(); + for (index, op) in ops.iter().enumerate() { + let (stream, end) = pieces.pieces_of(op); + let object = streams.get(stream).and_then(|(r, _)| r.as_ref()); + if object.is_some_and(|r| owned.contains(&r)) { + if let std::collections::btree_map::Entry::Vacant(entry) = first.entry(stream) { + if stream != end || !op.is("q") { + return Err(EditError::Invalid("an added item has an unsupported placement".into())); + } + entry.insert((index, matrix)); + } else if first.get(&stream).is_some_and(|(i, _)| i.checked_add(1) == Some(index)) { + if stream != end || !op.is("cm") { + return Err(EditError::Invalid("an added item has an unsupported placement".into())); + } + let current = op.nums::<6>().map(Matrix).ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?; + let outside = first.get(&stream).map_or(Matrix::IDENTITY, |(_, m)| *m); + let params = object + .and_then(|r| doc.get(r).as_dict().and_then(|d| d.get(b"PCAdded")).map(|p| doc.resolve(p))) + .and_then(|p| p.as_dict().cloned()) + .ok_or_else(|| EditError::Invalid("an added item has no parameters".into()))?; + let origin = match params.get(b"MoveOrigin") { + None => current, + Some(o) => doc + .resolve(o) + .as_array() + .and_then(|a| Matrix::from_operands(a)) + .ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?, + }; + let raw = params + .get(b"Rect") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_array().cloned()) + .ok_or_else(|| EditError::Invalid("an added item has no rectangle".into()))?; + let local_rect = raw + .iter() + .map(Object::as_f64) + .collect::>>() + .and_then(|r| <[f64; 4]>::try_from(r).ok()) + .ok_or_else(|| EditError::Invalid("an added item has invalid geometry".into()))?; + if !current.0.iter().chain(origin.0.iter()).chain(outside.0.iter()).all(|v| v.is_finite() && v.abs() <= 1e12) + || !valid_rect(local_rect) + { + return Err(EditError::Invalid("an added item has invalid geometry".into())); + } + geometry.insert( + stream, + AddedGeometry { rect: origin.then(&outside).bbox(local_rect), local_rect, origin, outside, current, stream, op: index, params }, + ); + } + } + match op.op.as_slice() { + b"q" => { + if stack.len() >= 1024 { + return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); + } + stack.push(matrix); + } + b"Q" => matrix = stack.pop().unwrap_or(matrix), + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + _ => {} + } + } + let geometry: Vec<_> = geometry.into_values().collect(); + if geometry.len() != added.len() { + return Err(EditError::Invalid("an added item has no placement".into())); + } + Ok((pieces, geometry)) +} + +fn translate_added(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { + if indexes.is_empty() { + return Ok(()); + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + let streams = checked_streams(doc, page)?; + let (pieces, geometry) = added_geometry(doc, page)?; + let ops = pieces.parse().ops; + let mut replacements = BTreeMap::new(); + let mut parameters = Vec::new(); + let linear = |inverse: Matrix| { + let [a, b, c, d, _, _] = inverse.0; + [a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]] + }; + for index in indexes { + let g = geometry.get(*index).ok_or_else(|| EditError::Invalid("a selected added item no longer exists".into()))?; + let delta = linear(g.origin.then(&g.outside).invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?); + let outer_delta = linear(g.outside.invert().ok_or_else(|| EditError::Invalid("an added item has no area".into()))?); + let current = g.current.then(&Matrix([1.0, 0.0, 0.0, 1.0, outer_delta[0], outer_delta[1]])); + let r = g.local_rect; + let rect = [r[0] + delta[0], r[1] + delta[1], r[2] + delta[0], r[3] + delta[1]]; + if !valid_rect(rect) || !current.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the added item move is too large".into())); + } + let mut params = g.params.clone(); + params.set(b"MoveOrigin".to_vec(), Object::Array(g.origin.0.map(Object::Real).to_vec())); + params.set(b"Rect".to_vec(), Object::Array(rect.map(Object::Real).to_vec())); + parameters.push((g.stream, params)); + replacements.insert(g.op, Op::new("cm", current.0.map(Object::Real).to_vec())); + } + // A placement replaces exactly one operator. Keep its existing following separator, + // rather than adding a blank line every time the item is moved. + let edits = replacements + .into_iter() + .map(|(i, op)| { + let original = ops.get(i).ok_or_else(|| EditError::Invalid("an added item placement is missing".into()))?; + let mut bytes = pdfcraft_content::serialize_ops(&[op]); + if bytes.last() == Some(&b'\n') { + bytes.pop(); + } + Ok((original.span.clone(), bytes)) + }) + .collect::, EditError>>()?; + let data = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, &streams, data); + let refs = match &contents { + Object::Array(a) => a.clone(), + one => vec![one.clone()], + }; + for (stream, params) in parameters { + let r = refs.get(stream).and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("the added item stream is missing".into()))?; + doc.update_dict(r, |d| d.set(b"PCAdded".to_vec(), Object::Dict(params)))?; + } + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// A missing or undecodable preceding stream can hide graphics state that affects every +/// later item. Refuse group editing rather than inventing an inventory from partial data. +fn checked_streams(doc: &Document, page: usize) -> Result)>, EditError> { + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + let contents = match p.dict.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => { + if a.len() > MAX_PAGE_OBJECTS { + return Err(EditError::Invalid("this page has too many content streams to select objects together".into())); + } + a.clone() + } + _ => vec![c.clone()], + }, + }; + let mut remaining = MAX_CONTENT_BYTES; + let mut depth = 0usize; + let mut operations = 0usize; + let mut streams = Vec::with_capacity(contents.len()); + for object in contents { + let resolved = doc.resolve(&object); + let Object::Stream(stream) = &*resolved else { + return Err(EditError::Invalid("a page content stream is missing".into())); + }; + let bytes = stream + .decoded_within(remaining.min(64 * 1024 * 1024)) + .map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?; + remaining = + remaining.checked_sub(bytes.len()).ok_or_else(|| EditError::Invalid("the page content is too large for group selection".into()))?; + let parsed = pdfcraft_content::parse(&bytes); + operations = operations.saturating_add(parsed.ops.len()); + if operations > 100_000 { + return Err(EditError::Invalid("this page has too many content operations for group selection".into())); + } + for op in parsed.ops { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + depth = depth.saturating_sub(1); + } + if depth > 1024 { + return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); + } + } + streams.push((object, bytes)); + } + Ok(streams) +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 10750dcc3..d9f7f5481 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1646,3 +1646,385 @@ fn hostile_form_xobjects_are_read_once() { assert_eq!(texts, ["once", "from b", "from a", "inherited", "bad matrix"]); assert!(images::reading_images(&doc, 0).unwrap().is_empty()); } + +#[test] +fn group_moves_preserve_glyph_bytes_and_unselected_text_state() { + for content in [ + "BT /F1 12 Tf 1.3 Tc 2 Tw 80 Tz 14 TL 1 0.2 -0.1 1 50 600 Tm (A\\101) Tj 1 0 0 rg (Neighbour) Tj T* (Next) Tj ET", + "BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg (Quoted) ' 1 0 0 rg (After) Tj ET", + "BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2 1 (Quoted) \" 1 0 0 rg [(After) -120 (wards)] TJ T* (Last) Tj ET", + "q 1.2 0.3 -0.1 0.9 20 30 cm BT /F1 12 Tf 50 600 Td [(A) -120 (B)] TJ 1 0 0 rg (After) Tj ET Q %keep-this-comment\n", + "BT /F1 12.3456789 Tf 81.23456789 Tz 0.123456789 Ts 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2.123456789 1.987654321 (Quoted) \" 1 0 0 rg (After) Tj ET", + ] { + let mut doc = text_page(content); + let before = text::text_lines(&doc, 0).unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.text.contains("Quoted")).unwrap_or(0); + let selected: Vec<_> = blocks[index].lines.iter().map(|i| before[*i].text.clone()).collect(); + let glyph_bytes = |doc: &Document| { + pdfcraft_content::parse(&page_content_bytes(doc, 0)) + .ops + .iter() + .flat_map(|op| { + op.operands + .iter() + .flat_map(|o| match o { + Object::String(s) => vec![s.bytes.clone()], + Object::Array(a) => a.iter().filter_map(|v| v.as_string().map(|s| s.bytes.clone())).collect(), + _ => Vec::new(), + }) + .collect::>() + }) + .collect::>() + }; + let original_bytes = glyph_bytes(&doc); + let target = ObjectTarget { kind: ObjectKind::Text, index }; + move_objects(&mut doc, 0, &[target], [35.0, -27.0]).unwrap(); + let doc = reopen(&doc); + assert_eq!(glyph_bytes(&doc), original_bytes, "original codes and kerning strings survive"); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), before.len(), "{content}"); + for old in before { + let new = after.iter().find(|l| l.text == old.text).unwrap(); + let expected = if selected.contains(&old.text) { + [old.rect[0] + 35.0, old.rect[1] - 27.0, old.rect[2] + 35.0, old.rect[3] - 27.0] + } else { + old.rect + }; + assert!(new.rect.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{}: {:?} != {:?}", old.text, new.rect, expected); + assert_eq!((new.font.clone(), new.color), (old.font, old.color)); + } + if content.contains("%keep-this-comment") { + assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("%keep-this-comment")); + } + } +} + +#[test] +fn group_moves_preserve_small_offsets_under_large_transforms() { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let content = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 100000 0 0 100000 100 100 cm /Im0 Do Q q 10000 0 0 10000 0 0 cm BT /F1 0.00123456789 Tf 0.02 0.06 Td (Small) Tj 1 0 0 rg (Neighbour) Tj ET Q", + ))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap(); + let before = editable_objects(&doc, 0).unwrap(); + let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::>(); + assert_eq!(targets.len(), 2); + let offset = [0.00123456789, -0.00987654321]; + move_objects(&mut doc, 0, &targets, offset).unwrap(); + let after = editable_objects(&reopen(&doc), 0).unwrap(); + for old in before { + let new = after.iter().find(|o| o.target == old.target).unwrap(); + let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] }; + assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}"); + } +} + +#[test] +fn mixed_object_inventory_does_not_select_added_content_twice() { + let mut doc = image_page(); + add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added label".into(), rect: [50.0, 350.0, 200.0, 380.0], ..AddedText::default() })) + .unwrap(); + let before = editable_objects(&doc, 0).unwrap(); + assert_eq!(before.len(), 3, "one native paragraph, one native image, one added text"); + let targets = before.iter().map(|o| o.target).collect::>(); + let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap(); + let original_image = doc.get(image).clone(); + move_objects(&mut doc, 0, &targets, [20.0, -15.0]).unwrap(); + let doc = reopen(&doc); + let after = editable_objects(&doc, 0).unwrap(); + assert_eq!(after.len(), before.len()); + for old in before { + let new = after.iter().find(|o| o.target == old.target).unwrap(); + assert!(close(new.rect, [old.rect[0] + 20.0, old.rect[1] - 15.0, old.rect[2] + 20.0, old.rect[3] - 15.0])); + assert_eq!(new.text, old.text); + } + assert_eq!(doc.get(image).clone(), original_image, "pixel data is shared, not recompressed"); +} + +#[test] +fn group_move_refusals_leave_the_object_graph_untouched() { + let mut doc = image_page(); + let target = editable_objects(&doc, 0).unwrap()[0].target; + let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + for (targets, offset) in [ + (vec![], [1.0, 2.0]), + (vec![target, target], [1.0, 2.0]), + (vec![target, ObjectTarget { kind: ObjectKind::Image, index: usize::MAX }], [1.0, 2.0]), + (vec![target], [f64::NAN, 2.0]), + (vec![target], [f64::INFINITY, 2.0]), + (vec![target], [0.0, 0.0]), + (vec![target], [1e300, 2.0]), + (vec![target; MAX_MOVE_OBJECTS + 1], [1.0, 2.0]), + ] { + assert!(move_objects(&mut doc, 0, &targets, offset).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), original); + } + let mut clipped = text_page("BT /F1 12 Tf 50 600 Td (Ordinary) Tj ET BT /F1 12 Tf 4 Tr 50 400 Td (Clipping) Tj ET"); + let targets = editable_objects(&clipped, 0).unwrap().iter().map(|o| o.target).collect::>(); + let original = pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap(); + assert!(move_objects(&mut clipped, 0, &targets, [10.0, 5.0]).unwrap_err().to_string().contains("clipping")); + assert_eq!(pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap(), original); +} + +#[test] +fn added_and_native_objects_move_together_on_cropped_rotated_user_unit_pages() { + for rotation in [0, 90, 180, 270] { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.set(b"CropBox".to_vec(), Object::Array([10, 20, 500, 750].map(Object::Int).to_vec())); + d.set(b"Rotate".to_vec(), Object::Int(rotation)); + d.set(b"UserUnit".to_vec(), Object::Int(2)); + }) + .unwrap(); + add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added".into(), rect: [60.0, 100.0, 180.0, 125.0], ..AddedText::default() })) + .unwrap(); + let before = editable_objects(&doc, 0).unwrap(); + move_objects(&mut doc, 0, &before.iter().map(|o| o.target).collect::>(), [12.0, -8.0]).unwrap(); + let after = editable_objects(&reopen(&doc), 0).unwrap(); + for (a, b) in before.iter().zip(after) { + assert!( + close(b.rect, [a.rect[0] + 12.0, a.rect[1] - 8.0, a.rect[2] + 12.0, a.rect[3] - 8.0]), + "rotation {rotation}: {:?} -> {:?}", + a.rect, + b.rect + ); + } + } +} + +#[test] +fn group_moves_keep_split_stream_tokens_and_shared_form_resources() { + let mut doc = split_streams_page(); + let before = text::text_lines(&doc, 0).unwrap(); + let objects: Vec<_> = editable_objects(&doc, 0).unwrap().iter().map(|o| o.target).collect(); + move_objects(&mut doc, 0, &objects, [30.0, -20.0]).unwrap(); + let doc = reopen(&doc); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.len(), after.len()); + for (a, b) in before.iter().zip(&after) { + assert_eq!(a.text, b.text); + assert!(close(b.rect, [a.rect[0] + 30.0, a.rect[1] - 20.0, a.rect[2] + 30.0, a.rect[3] - 20.0])); + } + let joined = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&joined).ops; + assert_eq!(ops.iter().find(|o| o.is("BDC")).unwrap().operands.len(), 2); + assert!(ops.iter().any(|o| o.is("EMC"))); + // Two pages share a content stream and a Form; only one placement on one page moves. + let mut doc = fixture(); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec())); + form.set(b"PrivateData".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"retain this".to_vec()))); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 0 0 60 30 re f"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + let mut xo = Dict::new(); + xo.set(b"Figure".to_vec(), Object::Ref(artwork)); + d.set(b"XObject".to_vec(), Object::Dict(xo)); + }) + .unwrap(); + doc.set( + pdfcraft_cos::ObjRef::new(7, 0), + Object::Stream(Stream::flate( + Dict::new(), + b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf 40 160 Td (Caption) Tj ET", + )), + ); + let original_form = doc.get(artwork); + let source_page = page_content_bytes(&doc, 1); + let images = images::page_images(&doc, 0).unwrap(); + move_objects( + &mut doc, + 0, + &[ObjectTarget { kind: ObjectKind::Image, index: 0 }, ObjectTarget { kind: ObjectKind::Text, index: 0 }], + [15.0, -10.0], + ) + .unwrap(); + let after = images::page_images(&doc, 0).unwrap(); + assert!(close(after[0].rect, [55.0, 70.0, 115.0, 100.0])); + assert_eq!(after[1].rect, images[1].rect); + assert_eq!(*doc.get(artwork), *original_form); + assert_eq!(page_content_bytes(&doc, 1), source_page); +} + +#[test] +fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() { + let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET"); + let mut font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + ]); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 3 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> endbfchar", + ))); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + font.set( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([(b"Subtype".to_vec(), Object::name("CIDFontType2")), (b"DW".to_vec(), Object::Int(600))]))]), + ); + // No external font asset: this synthetic font is enough to exercise raw CID bytes. + let p = pdfcraft_model::pages(&doc)[0].obj; + let mut resources = Dict::new(); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font.clone())); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap(); + let original = text::text_lines(&doc, 0).unwrap(); + assert!(!original.is_empty()); + move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [25.0, 15.0]).unwrap(); + let encoded = + |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.into_iter().find(|o| o.is("Tj")).unwrap().operands[0].clone(); + assert_eq!(encoded(&doc).as_string().unwrap().bytes, [0, 65, 0, 66, 0, 67]); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(original[0].text, after[0].text); + for encoding in [ + Object::name("Identity-V"), + Object::Stream(Stream::flate(Dict::new(), b"/WMode 1 def")), + Object::Stream(Stream::flate(Dict::from_iter([(b"WMode".to_vec(), Object::Int(1))]), b"")), + ] { + let mut f = font.clone(); + f.set(b"Encoding".to_vec(), encoding); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(f)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap(); + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [5.0, 5.0]).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn moving_added_content_after_page_rotation_preserves_streams_and_resources() { + let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Native) Tj ET"); + added::add_content( + &mut doc, + 0, + &Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 300.0, 200.0, 330.0], ..Default::default() }), + ) + .unwrap(); + let p = pdfcraft_model::pages(&doc)[0].obj; + let resources = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned(); + let item = added::list_added(&doc)[0].clone(); + let source = stream_bytes(&doc, &Object::Ref(item.obj)).unwrap(); + let content_after_cm = |bytes: &[u8]| { + let ops = pdfcraft_content::parse(bytes).ops; + bytes[ops[1].span.end..].to_vec() + }; + for rotation in [90, 180, 270, 0] { + doc.update_dict(p, |d| d.set(b"Rotate".to_vec(), Object::Int(rotation))).unwrap(); + let before_lines = text::text_lines(&doc, 0).unwrap(); + let before_objects = editable_objects(&doc, 0).unwrap(); + let a = before_objects.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap(); + // The logical added box must contain the actual source glyph geometry after rotation. + let label = before_lines.iter().find(|l| l.text == "Added label").unwrap(); + assert!( + label.rect[0] >= a.rect[0] - 0.01 + && label.rect[2] <= a.rect[2] + 0.01 + && label.rect[1] >= a.rect[1] - 0.01 + && label.rect[3] <= a.rect[3] + 0.01 + ); + move_objects(&mut doc, 0, &[a.target], [13.0, -17.0]).unwrap(); + let after_lines = text::text_lines(&doc, 0).unwrap(); + for (old, new) in before_lines.iter().zip(&after_lines) { + let delta = if old.text == "Added label" { [13.0, -17.0] } else { [0.0, 0.0] }; + assert_eq!(old.text, new.text); + assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]])); + } + let current = added::list_added(&doc)[0].clone(); + let bytes = stream_bytes(&doc, &Object::Ref(current.obj)).unwrap(); + assert_eq!(content_after_cm(&bytes), content_after_cm(&source), "all drawing bytes after the placement, including glyphs, remain identical"); + assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned(), resources); + doc = reopen(&doc); + } +} + +#[test] +fn group_moves_of_shared_added_streams_change_only_the_selected_page() { + let mut doc = fixture(); + added::add_content( + &mut doc, + 0, + &Content::Text(added::AddedText { text: "Shared added label".into(), rect: [40.0, 400.0, 240.0, 420.0], ..Default::default() }), + ) + .unwrap(); + let pages = pdfcraft_model::pages(&doc); + let contents = pages[0].dict.get(b"Contents").cloned().unwrap(); + doc.update_dict(pages[1].obj, |d| d.set(b"Contents".to_vec(), contents)).unwrap(); + let doc = reopen(&doc); + let mut doc = doc; + let other = page_content_bytes(&doc, 1); + let before = editable_objects(&doc, 0).unwrap(); + let target = before.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target; + move_objects(&mut doc, 0, &[target], [15.0, -20.0]).unwrap(); + assert_eq!(page_content_bytes(&doc, 1), other, "a shared stream is copied on write"); + assert_ne!(added::list_added(&doc).iter().find(|a| a.page == 0).unwrap().obj, added::list_added(&doc).iter().find(|a| a.page == 1).unwrap().obj); +} + +#[test] +fn group_moves_handle_added_content_under_inherited_graphics_state() { + let mut doc = text_page("2 0 0 2 10 20 cm BT /F1 12 Tf 40 250 Td (Native) Tj ET"); + added::add_content( + &mut doc, + 0, + &Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 100.0, 200.0, 130.0], ..Default::default() }), + ) + .unwrap(); + let before = text::text_lines(&doc, 0).unwrap(); + let target = editable_objects(&doc, 0).unwrap().iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target; + move_objects(&mut doc, 0, &[target], [30.0, -20.0]).unwrap(); + let after = text::text_lines(&doc, 0).unwrap(); + for (old, new) in before.iter().zip(&after) { + let delta = if old.text == "Added label" { [30.0, -20.0] } else { [0.0, 0.0] }; + assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]])); + } +} + +#[test] +fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { + for data in [ + format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "q ".repeat(1025)), + format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(100001)), + ] { + let mut doc = text_page(&data); + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(editable_objects(&doc, 0).is_err()); + assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [10.0, 10.0]).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + } + let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Target) Tj ET"); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(999999, 0)))).unwrap(); + assert!(editable_objects(&doc, 0).is_err(), "a missing stream cannot produce a partial inventory"); + let mut filter = Dict::new(); + filter.set(b"Filter".to_vec(), Object::name("NotAFilter")); + let broken = doc.add(Object::Stream(Stream::from_raw(filter, vec![1, 2, 3]))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(broken))).unwrap(); + assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); +} + +#[test] +fn group_move_budget_refusals_keep_the_original_page_selectable() { + let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995))); + assert_eq!(pdfcraft_content::parse(&page_content_bytes(&text, 0)).ops.len(), 100_000); + let mut image = image_page(); + let page = pdfcraft_model::pages(&image)[0].obj; + let bytes = format!("{} /Im0 Do {}", "q ".repeat(1024), "Q ".repeat(1024)); + let content = image.add(Object::Stream(Stream::flate(Dict::new(), bytes.as_bytes()))); + image.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap(); + for mut doc in [text, image] { + let objects = editable_objects(&doc, 0).unwrap(); + assert_eq!(objects.len(), 1); + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let error = move_objects(&mut doc, 0, &[objects[0].target], [10.0, 5.0]).unwrap_err(); + assert!(error.to_string().contains("too many content operations") || error.to_string().contains("nested too deeply"), "{error}"); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 999858c2e..2d1b07f1f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -148,6 +148,7 @@ struct Ts { scale: f64, leading: f64, rise: f64, + render_mode: i64, } fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { @@ -175,7 +176,7 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { /// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and /// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns /// each piece's new bytes. -fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec> { +pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec> { let data = pieces.data(); let mut edits = Vec::new(); for (i, op) in ops.iter().enumerate() { @@ -198,7 +199,7 @@ fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, /// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams /// (keeping their dictionaries), the others stay as they were. -fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { +pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { let mut contents: Vec = Vec::with_capacity(streams.len()); for ((obj, old), data) in streams.iter().zip(new) { if *old == data { @@ -290,6 +291,7 @@ impl Carry { scale: 1.0, leading: 0.0, rise: 0.0, + render_mode: 0, }; Carry { ts, stack: Vec::new() } } @@ -356,6 +358,7 @@ fn interpret( b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0, b"TL" => ts.leading = op.num(0).unwrap_or(0.0), b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), + b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"Do" => { if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { list.push((i, name.to_vec(), ts.clone())); @@ -437,6 +440,11 @@ fn interpret( .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + continue; + } out.push(Shown { op: i, tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), @@ -1366,6 +1374,173 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option Ok(LineEdit { substituted }) } +/// Existing paragraph indexes, excluding streams owned by an added-content item. +pub(crate) fn selectable_blocks( + doc: &Document, + page: usize, + excluded: &std::collections::BTreeSet, +) -> Result, EditError> { + let p = page_dict(doc, page)?; + let streams = content_streams(doc, &p.dict); + let lines = text_lines(doc, page)?; + Ok(group_blocks(&lines) + .into_iter() + .enumerate() + .filter(|(_, b)| { + !b.lines + .iter() + .filter_map(|i| lines.get(*i)) + .any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) + }) + .collect()) +} + +/// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is +/// refused rather than guessing which direction TJ advances its text matrix. +fn vertical_encoding(doc: &Document, font: &Dict) -> Result { + if font.name(b"Subtype") != Some(b"Type0") { + return Ok(false); + } + let mut next = font.get(b"Encoding").cloned(); + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..8 { + let Some(o) = next.take() else { return Ok(false) }; + if let Some(r) = o.as_ref() + && !seen.insert(r) + { + return Err(EditError::Invalid("the text encoding is cyclic".into())); + } + match &*doc.resolve(&o) { + Object::Name(n) => return Ok(n.ends_with(b"-V")), + Object::Stream(stream) => { + if let Some(mode) = stream.dict.get(b"WMode") { + match mode.as_int() { + Some(1) => return Ok(true), + Some(0) => {} + _ => return Err(EditError::Invalid("the text writing mode is invalid".into())), + } + } + let bytes = stream + .decoded_within(8 * 1024 * 1024) + .map_err(|_| EditError::Invalid("the text encoding cannot be decoded within its size limit".into()))?; + let ops = parse(&bytes).ops; + if let Some(mode) = ops.iter().rev().find(|op| op.is("def") && op.name(0) == Some(b"WMode")) { + return match mode.num(1) { + Some(0.0) => Ok(false), + Some(1.0) => Ok(true), + _ => Err(EditError::Invalid("the text writing mode is invalid".into())), + }; + } + next = stream + .dict + .get(b"UseCMap") + .cloned() + .or_else(|| ops.iter().rev().find(|op| op.is("usecmap")).and_then(|op| op.operands.first().cloned())); + } + _ => return Err(EditError::Invalid("the text encoding is invalid".into())), + } + } + Err(EditError::Invalid("the text encoding is nested too deeply".into())) +} + +/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing. +/// Numeric TJ offsets and text rise temporarily move each selected show operation, then +/// restore both advances and rise. The PDF reader advances the original glyph bytes itself: +/// no approximate font metrics are used to position following, unselected text (§9.4.4). +pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { + if indexes.is_empty() { + return Ok(()); + } + let p = page_dict(doc, page)?; + let streams = content_streams(doc, &p.dict); + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + let mut selected = std::collections::BTreeSet::new(); + for index in indexes { + let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; + for line in &b.lines { + let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; + selected.extend(l.ops.iter().copied()); + } + } + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None); + let mut patches = HashMap::new(); + // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, + // and restoring rise/spacing must not alter following unselected text. + let n = Object::Real; + for s in shown.into_iter().filter(|s| selected.contains(&s.op)) { + if !(0..=3).contains(&s.state.render_mode) { + return Err(EditError::Invalid("text used as a clipping path cannot be moved in a group".into())); + } + let font = fonts + .get(&s.font) + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .ok_or_else(|| EditError::Invalid("a selected paragraph has no font".into()))?; + if vertical_encoding(doc, &font)? { + return Err(EditError::Invalid("vertical text cannot be moved in a group".into())); + } + let inverse = s.tm.then(&s.state.ctm).invert().ok_or_else(|| EditError::Invalid("a selected paragraph has no area".into()))?; + let [a, b, c, d, _, _] = inverse.0; + let (dx, dy) = (a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]); + let denominator = s.state.size * s.state.scale; + let shift = if dx == 0.0 { + 0.0 + } else if denominator.abs() > 1e-12 { + -1000.0 * dx / denominator + } else { + return Err(EditError::Invalid("a selected paragraph has no horizontal advance".into())); + }; + let rise = s.state.rise + dy; + if ![shift, rise, dx, dy].iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the paragraph move is too large".into())); + } + let original = ops.get(s.op).ok_or_else(|| EditError::Invalid("a selected text operation is missing".into()))?; + let mut replacement = Vec::new(); + let show = if original.is("'") || original.is("\"") { + if original.is("\"") { + let spacing = original + .operands + .get(..2) + .filter(|a| a.iter().all(|o| o.as_f64().is_some())) + .ok_or_else(|| EditError::Invalid("a text operation has invalid spacing".into()))?; + for (operator, operand) in ["Tw", "Tc"].into_iter().zip(spacing) { + replacement.push(Op::new(operator, vec![operand.clone()])); + } + } + replacement.push(Op::new("T*", vec![])); + Op::new("Tj", vec![original.operands.last().cloned().ok_or_else(|| EditError::Invalid("a text operation has no string".into()))?]) + } else { + original.clone() + }; + if dy != 0.0 { + replacement.push(Op::new("Ts", vec![n(rise)])); + } + if shift != 0.0 { + replacement.push(Op::new("TJ", vec![Object::Array(vec![n(shift)])])); + } + replacement.push(show); + if shift != 0.0 { + replacement.push(Op::new("TJ", vec![Object::Array(vec![n(-shift)])])); + } + if dy != 0.0 { + replacement.push(Op::new("Ts", vec![n(s.state.rise)])); + } + patches.insert(s.op, replacement); + } + if patches.len() != selected.len() { + return Err(EditError::Invalid("a selected text operation cannot be moved".into())); + } + let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); + let contents = rewritten_contents(doc, &streams, data); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0af719cbf..1c059a22a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -50,6 +50,8 @@ pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undraw pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; +pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] pub enum ImageEdit { @@ -273,6 +275,32 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() } + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + /// Save image as: image `index` on `page` as a file (extension, bytes). pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { let editor = self.editor.as_ref().ok_or("the document can't be read")?; @@ -1039,6 +1067,13 @@ pub enum Edit { index: usize, change: ImageEdit, }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), /// rewrapped to the box's width. EditTextBlock { @@ -1228,6 +1263,7 @@ impl Edit { Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(), Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(), + Edit::MoveObjects { objects, .. } => plural("Move object", objects.len()), Edit::EditPageImage { change, .. } => match change { ImageEdit::Move(_) => "Move image".into(), ImageEdit::Rotate(_) => "Rotate image".into(), @@ -1380,6 +1416,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::ClearRedactions @@ -1680,6 +1717,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e0d9390f6..eee3f7ca5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2818,6 +2818,143 @@ fn oversized_exports_clamp_and_report_their_dpi_unless_strict() { assert!(err.contains("exceeds renderer limits"), "{err}"); } +#[test] +fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() { + let (mut s, id) = session_with(1); + let text = AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text }).unwrap(); + let image = image::RgbImage::from_pixel(12, 8, image::Rgb([240, 20, 20])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "red.png".into(), bytes: Arc::new(png.into_inner()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).unwrap(); + let before = s.get(id).unwrap().editable_objects(0).unwrap(); + assert_eq!(before.len(), 3, "one native paragraph, one added label and one added image"); + let generation = s.get(id).unwrap().edit_generation(); + let targets = before.iter().map(|o| o.target).collect(); + s.apply(id, Edit::MoveObjects { page: 0, objects: targets, offset: [24.0, -24.0] }).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.dirty && doc.edit_generation() > generation); + assert_eq!(doc.can_undo(), Some("Move objects")); + let after = doc.editable_objects(0).unwrap(); + for (a, b) in before.iter().zip(&after) { + assert_eq!(a.text, b.text); + for (i, (x, y)) in a.rect.iter().zip(b.rect).enumerate() { + assert!((y - x - if i % 2 == 0 { 24.0 } else { -24.0 }).abs() < 0.001); + } + } + let moved = s.save_bytes(id).unwrap(); + assert!(moved.starts_with(saved.as_ref()), "an incremental update preserves the original revision"); + let mut again = Session::new(); + let other = again.open("moved.pdf", None, moved.clone(), None).unwrap(); + assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after); + assert_eq!(s.undo(id).unwrap(), "Move objects"); + assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects"); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), after); + let mut renderer = pdfcraft_render::PageRenderer::new(moved, Default::default()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let pixel = |x: usize, y: usize| &rendered.rgba[(y * rendered.width as usize + x) * 4..][..3]; + assert!(pixel(84, 269)[0] > 200 && pixel(84, 269)[1] < 50, "the added image renders at its new position"); + assert!(pixel(45, 240).iter().all(|v| *v > 240), "the old image position is clear"); +} + +#[test] +fn failed_group_moves_preserve_generation_dirty_state_and_redo_history() { + let (mut s, id) = session_with(1); + let objects: Vec<_> = s.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect(); + s.apply(id, Edit::MoveObjects { page: 0, objects: objects.clone(), offset: [10.0, 0.0] }).unwrap(); + s.undo(id).unwrap(); + let before = s.save_bytes(id).unwrap(); + let generation = s.get(id).unwrap().edit_generation(); + let dirty = s.get(id).unwrap().dirty; + let mut bad = objects; + bad.push(ObjectTarget { kind: ObjectKind::Image, index: 999 }); + assert!(s.apply(id, Edit::MoveObjects { page: 0, objects: bad, offset: [10.0, 0.0] }).is_err()); + let doc = s.get(id).unwrap(); + assert_eq!(doc.edit_generation(), generation); + assert_eq!(doc.dirty, dirty); + assert!(doc.can_undo().is_none()); + assert_eq!(doc.can_redo(), Some("Move object")); + assert_eq!(s.save_bytes(id).unwrap(), before); + s.redo(id).unwrap(); + assert!(s.get(id).unwrap().dirty); +} + +#[test] +fn group_move_respects_modify_permission_and_display_coordinate_rotation() { + for rotation in [0, 90, 180, 270] { + let (mut s, id) = session_with(1); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap(); + let doc = s.get(id).unwrap(); + let delta = doc.object_move_offset(0, [17.0, 23.0]).unwrap(); + let info = &doc.info.pages[0]; + let a = info.user_to_view(30.0, 70.0); + let b = info.user_to_view(30.0 + delta[0] as f32, 70.0 + delta[1] as f32); + assert!((b[0] - a[0] - 17.0).abs() < 0.001 && (b[1] - a[1] - 23.0).abs() < 0.001); + } + let (mut s, id) = session_with(1); + let mut p = protection(None, Some("owner")); + p.changes = Changes::None; + s.apply(id, Edit::Protect(p)).unwrap(); + let bytes = s.save_bytes(id).unwrap(); + let mut reader = Session::new(); + let id = reader.open("restricted.pdf", None, bytes, None).unwrap(); + let objects = reader.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect(); + let generation = reader.get(id).unwrap().edit_generation(); + assert!(reader.apply(id, Edit::MoveObjects { page: 0, objects, offset: [10.0, 0.0] }).is_err()); + let doc = reader.get(id).unwrap(); + assert!(!doc.dirty && doc.can_undo().is_none() && doc.edit_generation() == generation); +} + +#[test] +fn group_moves_preserve_rotated_added_content_raster_geometry() { + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddText { page: 0, text: AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() } }) + .unwrap(); + let image = image::RgbImage::from_fn(20, 10, |x, _| image::Rgb(if x < 10 { [240, 20, 20] } else { [20, 20, 240] })); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "quadrants.png".into(), bytes: Arc::new(png.into_inner()) }) + .unwrap(); + let AddedContent::Image(mut image) = s.get(id).unwrap().added[1].content.clone() else { panic!() }; + image.flip_h = true; + image.crop = [0.1, 0.0, 0.2, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(image) }).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let render = |bytes| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, Default::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + let before = render(s.get(id).unwrap().bytes.clone()); + let doc = s.get(id).unwrap(); + let offset = doc.object_move_offset(0, [24.0, 36.0]).unwrap(); + let objects = doc.editable_objects(0).unwrap().iter().map(|o| o.target).collect(); + s.apply(id, Edit::MoveObjects { page: 0, objects, offset }).unwrap(); + let after = render(s.get(id).unwrap().bytes.clone()); + assert_eq!((before.width, before.height), (after.width, after.height)); + let mut painted = 0; + for y in 0..before.height as usize { + for x in 0..before.width as usize { + let pos = (y * before.width as usize + x) * 4; + let expected = if x >= 24 && y >= 36 { + let old = ((y - 36) * before.width as usize + x - 24) * 4; + &before.rgba[old..old + 4] + } else { + &[255, 255, 255, 255] + }; + // Raster coverage can round by one intensity level when equivalent placements + // reach the renderer through different text operators. Geometry and image colours + // must still agree across every pixel of the translated page. + assert!(after.rgba[pos..pos + 4].iter().zip(expected).all(|(a, b)| a.abs_diff(*b) <= 2), "pixel {x},{y}"); + painted += usize::from(expected[..3].iter().any(|c| *c < 240)); + } + } + assert!(painted > 500, "the comparison contains real rendered text and image pixels"); +} + #[test] fn legacy_passwords_with_turkish_characters_open() { // RC4-128 (R3): user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0), owner "Çağlar" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 15560090c..b737e3c77 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,6 +353,7 @@ pub struct DocView { pub line_editor: Option, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, + pub(crate) objects: crate::object_ui::ObjectSelection, pub image_selection: Option, /// A paragraph box being dragged (moved, or resized from its right edge) in Edit text. pub block_drag: Option, @@ -545,6 +546,7 @@ impl DocView { edit_lines: HashMap::new(), line_editor: None, edit_images: HashMap::new(), + objects: Default::default(), image_selection: None, block_drag: None, pending_action: None, @@ -1970,6 +1972,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.prepare.selected = None; } + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); let added = doc.added.clone(); let doc_links = if tool == QuickTool::Link { doc.links.clone() } else { Vec::new() }; if editing_content { @@ -2270,11 +2273,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) }; - let on_content = editing_content && can_modify && { + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + let on_content = !on_objects && editing_content && can_modify && { crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) || tool == QuickTool::AddText }; - let on_edit_text = tool == QuickTool::EditText && can_modify && { + let on_edit_text = !on_objects && tool == QuickTool::EditText && can_modify && { let generation = doc.edit_generation(); let lines = match view.edit_lines.get(&i) { Some((g, l)) if *g == generation => l.clone(), @@ -2297,7 +2301,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { || crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view) }; let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); - let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); @@ -2429,6 +2433,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if editing_content { crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } if tool == QuickTool::Link { crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view); } @@ -2656,6 +2663,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if typed.is_some() { view.pending_edit = typed; } + if let Some(why) = view.objects.notice.take() { + refused = Some(why); + } let form_notice = view.forms.notice.take(); // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 7d0f10f03..1dab43e3e 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -371,7 +371,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Pressing a button takes the focus from the text: that is not a click away. let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); - commit = done || resp.lost_focus() && !on_buttons; + commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; }); // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). undrawable = self::undrawable(t, memo); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 946a3080c..c32b097b3 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -81,6 +81,10 @@ pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { } impl LineEditor { + pub(crate) fn has_changes(&self) -> bool { + self.text != self.original || self.look != self.look0 || self.extras != self.extras0 + } + /// How far the box may grow to the right, when the paragraph is a single line (a multi-line /// paragraph rewraps to its own width and the box doesn't grow). pub fn growth(&self) -> Option { @@ -501,7 +505,11 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let viewport_right = view.viewport_rect().right(); + let hold = view.objects.hold_editor; let ed = view.line_editor.as_mut()?; + if hold { + ed.focus = true; + } // Reproject the source box every frame. The page may have been zoomed, scrolled or rotated // while the format panel was open. ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0); @@ -544,7 +552,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if apply || (r.lost_focus() && !outside) { + } else if apply || (r.lost_focus() && !outside && !hold) { done = Some(true); } }); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 77ada1781..6c20eb22a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -85,7 +85,8 @@ impl PdfCraftApp { true } Err(e) => { - self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &e.to_string())]); + let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() }; + self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::action_label(&label)), ("e", &message)]); false } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index e0b878c9e..a6c9b8945 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2170,6 +2170,19 @@ certificate Subject صاحب الشهادة A selected field has no widget on any page, so the fields can't be edited together. أحد الحقول المحددة ليس له عنصر واجهة في أي صفحة، لذا لا يمكن تحرير الحقول معًا. Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). التعرف على النص غير مثبت: نماذجه مفقودة (شغّل `cargo xtask models`، أو اضبط PDFCRAFT_MODELS). ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} بدقة {dpi} dpi، وهي أكبر دقة يمكن رسمها بها) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected تم تحديد {n} عنصر|تم تحديد {n} عنصر|تم تحديد {n} عنصرين|تم تحديد {n} عناصر|تم تحديد {n} عنصرًا|تم تحديد {n} عنصر + Drag the selection to move it. Esc clears the selection. اسحب التحديد لنقله. يمسح Esc التحديد. + Shift-click to select several objects. Drag empty space to select an area. انقر مع الضغط على Shift لتحديد عدة عناصر. اسحب في مساحة فارغة لتحديد منطقة. + Select at most 1000 objects at a time. حدد 1000 عنصر كحد أقصى في المرة الواحدة. + Finish or discard the text being edited before selecting several objects. أكمل تحرير النص أو تجاهله قبل تحديد عدة عناصر. + Move object نقل عنصر + Move objects نقل عناصر + Vertical text cannot be moved with this selection. لا يمكن نقل النص العمودي مع هذا التحديد. + Text used as a clipping path cannot be moved with this selection. لا يمكن نقل النص المستخدم كمسار قص مع هذا التحديد. + These objects could not be moved. Select them again and try a smaller move. تعذر نقل هذه العناصر. حددها مجددًا وحاول نقلها بمسافة أقل. + This page contains objects that cannot be selected together. تحتوي هذه الصفحة على عناصر لا يمكن تحديدها معًا. Fill colour لون التعبئة No fill بلا تعبئة Fill colour: none لون التعبئة: بلا diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 47eba6a4e..adc042174 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2110,6 +2110,19 @@ action wizard Start Старт The field {name} no longer exists. Полето {name} вече не съществува. A selected field has no widget on any page, so the fields can't be edited together. Едно от избраните полета няма елемент на нито една страница, затова полетата не могат да се редактират заедно. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} с {dpi} dpi, най-голямата резолюция, с която могат да бъдат изобразени) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected Избран е {n} обект|Избрани са {n} обекта + Drag the selection to move it. Esc clears the selection. Плъзнете избраното, за да го преместите. Esc премахва избора. + Shift-click to select several objects. Drag empty space to select an area. Щракнете с Shift, за да изберете няколко обекта. Плъзнете в празно място, за да изберете област. + Select at most 1000 objects at a time. Изберете най-много 1000 обекта наведнъж. + Finish or discard the text being edited before selecting several objects. Завършете или отменете редактирането на текста, преди да изберете няколко обекта. + Move object Преместване на обект + Move objects Преместване на обекти + Vertical text cannot be moved with this selection. Вертикалният текст не може да се премести с този избор. + Text used as a clipping path cannot be moved with this selection. Текстът, използван като изрязващ контур, не може да се премести с този избор. + These objects could not be moved. Select them again and try a smaller move. Тези обекти не можаха да бъдат преместени. Изберете ги отново и опитайте по-малко отместване. + This page contains objects that cannot be selected together. Тази страница съдържа обекти, които не могат да се изберат заедно. Fill colour Цвят на запълване No fill Без запълване Fill colour: none Цвят на запълване: няма diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 60e08d1b0..a0b402969 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -152,6 +152,18 @@ {n} word(s) or phrase(s) Slova nebo fráze: {n} {name} is too large for a word list (at most 1 MB). {name} je pro seznam slov příliš velký (nejvýše 1 MB). +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected Vybrán {n} objekt|Vybrány {n} objekty|Vybráno {n} objektů + Drag the selection to move it. Esc clears the selection. Přetažením výběr přesunete. Esc zruší výběr. + Shift-click to select several objects. Drag empty space to select an area. Kliknutím se Shiftem vyberete více objektů. Tažením v prázdném místě vyberete oblast. + Select at most 1000 objects at a time. Vyberte nejvýše 1000 objektů najednou. + Finish or discard the text being edited before selecting several objects. Před výběrem více objektů dokončete nebo zahoďte úpravu textu. + Move object Přesunout objekt + Move objects Přesunout objekty + Vertical text cannot be moved with this selection. Svislý text nelze přesunout s tímto výběrem. + Text used as a clipping path cannot be moved with this selection. Text použitý jako ořezová cesta nelze přesunout s tímto výběrem. + These objects could not be moved. Select them again and try a smaller move. Tyto objekty se nepodařilo přesunout. Vyberte je znovu a zkuste menší posun. + This page contains objects that cannot be selected together. Tato stránka obsahuje objekty, které nelze vybrat společně. # Keyboard key names, distinct from command labels. key Ctrl Ctrl key Shift Shift diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7955431d6..268e180c9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2148,6 +2148,19 @@ action wizard Start Starten The field {name} no longer exists. Das Feld {name} existiert nicht mehr. A selected field has no widget on any page, so the fields can't be edited together. Ein ausgewähltes Feld hat auf keiner Seite ein Widget, daher können die Felder nicht gemeinsam bearbeitet werden. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} mit {dpi} dpi, der größten Auflösung, in der sie gezeichnet werden können) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} Objekt ausgewählt|{n} Objekte ausgewählt + Drag the selection to move it. Esc clears the selection. Ziehen Sie die Auswahl, um sie zu verschieben. Esc hebt die Auswahl auf. + Shift-click to select several objects. Drag empty space to select an area. Mit Umschalt-Klick mehrere Objekte auswählen. Ziehen Sie in einem leeren Bereich, um einen Auswahlrahmen aufzuziehen. + Select at most 1000 objects at a time. Wählen Sie höchstens 1000 Objekte gleichzeitig aus. + Finish or discard the text being edited before selecting several objects. Schließen Sie die Textbearbeitung ab oder verwerfen Sie sie, bevor Sie mehrere Objekte auswählen. + Move object Objekt verschieben + Move objects Objekte verschieben + Vertical text cannot be moved with this selection. Vertikaler Text kann mit dieser Auswahl nicht verschoben werden. + Text used as a clipping path cannot be moved with this selection. Text, der als Beschneidungspfad dient, kann mit dieser Auswahl nicht verschoben werden. + These objects could not be moved. Select them again and try a smaller move. Diese Objekte konnten nicht verschoben werden. Wählen Sie sie erneut aus und versuchen Sie eine kleinere Verschiebung. + This page contains objects that cannot be selected together. Diese Seite enthält Objekte, die nicht gemeinsam ausgewählt werden können. Fill colour Füllfarbe No fill Keine Füllung Fill colour: none Füllfarbe: keine diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 270434096..10a80f4d8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2064,6 +2064,19 @@ The field {name} no longer exists. El campo {name} ya no existe. A selected field has no widget on any page, so the fields can't be edited together. Un campo seleccionado no tiene ningún widget en ninguna página, así que los campos no se pueden editar juntos. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} ppp, el mayor tamaño al que se pueden dibujar) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} objeto seleccionado|{n} objetos seleccionados + Drag the selection to move it. Esc clears the selection. Arrastra la selección para moverla. Esc borra la selección. + Shift-click to select several objects. Drag empty space to select an area. Pulsa Mayús y haz clic para seleccionar varios objetos. Arrastra en un espacio vacío para seleccionar un área. + Select at most 1000 objects at a time. Selecciona como máximo 1000 objetos a la vez. + Finish or discard the text being edited before selecting several objects. Termina o descarta la edición del texto antes de seleccionar varios objetos. + Move object Mover objeto + Move objects Mover objetos + Vertical text cannot be moved with this selection. El texto vertical no se puede mover con esta selección. + Text used as a clipping path cannot be moved with this selection. El texto usado como trazado de recorte no se puede mover con esta selección. + These objects could not be moved. Select them again and try a smaller move. No se pudieron mover estos objetos. Vuelve a seleccionarlos e intenta un desplazamiento menor. + This page contains objects that cannot be selected together. Esta página contiene objetos que no se pueden seleccionar juntos. Fill colour Color de relleno No fill Sin relleno Fill colour: none Color de relleno: ninguno diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index abf2163fd..36dacc3b5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2148,6 +2148,19 @@ signature pad Type Saisir The field {name} no longer exists. Le champ {name} n'existe plus. A selected field has no widget on any page, so the fields can't be edited together. Un champ sélectionné n'a de widget sur aucune page : les champs ne peuvent pas être modifiés ensemble. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} à {dpi} ppp, la plus grande taille possible pour les dessiner) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} objet sélectionné|{n} objets sélectionnés + Drag the selection to move it. Esc clears the selection. Faites glisser la sélection pour la déplacer. Échap annule la sélection. + Shift-click to select several objects. Drag empty space to select an area. Maj-clic pour sélectionner plusieurs objets. Faites glisser dans un espace vide pour sélectionner une zone. + Select at most 1000 objects at a time. Sélectionnez au maximum 1000 objets à la fois. + Finish or discard the text being edited before selecting several objects. Terminez ou abandonnez la modification du texte avant de sélectionner plusieurs objets. + Move object Déplacer un objet + Move objects Déplacer des objets + Vertical text cannot be moved with this selection. Le texte vertical ne peut pas être déplacé avec cette sélection. + Text used as a clipping path cannot be moved with this selection. Le texte utilisé comme chemin de découpe ne peut pas être déplacé avec cette sélection. + These objects could not be moved. Select them again and try a smaller move. Ces objets n’ont pas pu être déplacés. Sélectionnez-les à nouveau et essayez un déplacement plus petit. + This page contains objects that cannot be selected together. Cette page contient des objets qui ne peuvent pas être sélectionnés ensemble. Fill colour Couleur de fond No fill Sans fond Fill colour: none Couleur de fond : aucune diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0724c24db..dfd4ae7d4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2146,6 +2146,19 @@ action wizard Start Indítás The field {name} no longer exists. A(z) {name} mező már nem létezik. A selected field has no widget on any page, so the fields can't be edited together. Az egyik kijelölt mezőnek egyik oldalon sincs widgetje, ezért a mezők nem szerkeszthetők együtt. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi felbontással, a legnagyobb méretben, amelyben megjeleníthetők) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} objektum kijelölve|{n} objektum kijelölve + Drag the selection to move it. Esc clears the selection. A kijelölés áthelyezéséhez húzza azt. Az Esc törli a kijelölést. + Shift-click to select several objects. Drag empty space to select an area. Több objektum kijelöléséhez kattintson a Shift lenyomásával. Terület kijelöléséhez húzzon az üres részen. + Select at most 1000 objects at a time. Egyszerre legfeljebb 1000 objektumot jelöljön ki. + Finish or discard the text being edited before selecting several objects. Több objektum kijelölése előtt fejezze be vagy vesse el a szöveg szerkesztését. + Move object Objektum áthelyezése + Move objects Objektumok áthelyezése + Vertical text cannot be moved with this selection. A függőleges szöveg nem helyezhető át ezzel a kijelöléssel. + Text used as a clipping path cannot be moved with this selection. A vágógörbeként használt szöveg nem helyezhető át ezzel a kijelöléssel. + These objects could not be moved. Select them again and try a smaller move. Ezeket az objektumokat nem sikerült áthelyezni. Jelölje ki őket újra, és próbáljon kisebb elmozdítást. + This page contains objects that cannot be selected together. Ez az oldal olyan objektumokat tartalmaz, amelyek nem jelölhetők ki együtt. Fill colour Kitöltőszín No fill Nincs kitöltés Fill colour: none Kitöltőszín: nincs diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d3a81d4df..07b321fd6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2157,6 +2157,19 @@ signature pad Type Digita The field {name} no longer exists. Il campo {name} non esiste più. A selected field has no widget on any page, so the fields can't be edited together. Un campo selezionato non ha widget in nessuna pagina, quindi i campi non possono essere modificati insieme. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, la dimensione massima a cui si possono disegnare) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} oggetto selezionato|{n} oggetti selezionati + Drag the selection to move it. Esc clears the selection. Trascina la selezione per spostarla. Esc annulla la selezione. + Shift-click to select several objects. Drag empty space to select an area. Maiusc-clic per selezionare più oggetti. Trascina in uno spazio vuoto per selezionare un’area. + Select at most 1000 objects at a time. Seleziona al massimo 1000 oggetti alla volta. + Finish or discard the text being edited before selecting several objects. Completa o annulla la modifica del testo prima di selezionare più oggetti. + Move object Sposta oggetto + Move objects Sposta oggetti + Vertical text cannot be moved with this selection. Il testo verticale non può essere spostato con questa selezione. + Text used as a clipping path cannot be moved with this selection. Il testo usato come tracciato di ritaglio non può essere spostato con questa selezione. + These objects could not be moved. Select them again and try a smaller move. Non è stato possibile spostare questi oggetti. Selezionali di nuovo e prova uno spostamento più piccolo. + This page contains objects that cannot be selected together. Questa pagina contiene oggetti che non possono essere selezionati insieme. Fill colour Colore riempimento No fill Nessun riempimento Fill colour: none Colore riempimento: nessuno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a5b1bf4d9..7150e8dde 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2148,6 +2148,19 @@ signature pad Type 入力 The field {name} no longer exists. フィールド {name} はもう存在しません。 A selected field has no widget on any page, so the fields can't be edited together. 選択したフィールドの一つがどのページにもウィジェットを持たないため、まとめて編集できません。 ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 件は描画できる最大サイズの {dpi} dpi で書き出しました) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n}個のオブジェクトを選択中 + Drag the selection to move it. Esc clears the selection. 選択範囲をドラッグして移動します。Escで選択を解除します。 + Shift-click to select several objects. Drag empty space to select an area. Shiftを押しながらクリックすると複数のオブジェクトを選択できます。空白をドラッグすると範囲選択できます。 + Select at most 1000 objects at a time. 一度に選択できるオブジェクトは1000個までです。 + Finish or discard the text being edited before selecting several objects. 複数のオブジェクトを選択する前に、テキストの編集を完了するか破棄してください。 + Move object オブジェクトを移動 + Move objects 複数のオブジェクトを移動 + Vertical text cannot be moved with this selection. この選択範囲では縦書きのテキストを移動できません。 + Text used as a clipping path cannot be moved with this selection. この選択範囲ではクリッピングパスとして使われているテキストを移動できません。 + These objects could not be moved. Select them again and try a smaller move. これらのオブジェクトを移動できませんでした。選択し直し、移動量を小さくして試してください。 + This page contains objects that cannot be selected together. このページには一緒に選択できないオブジェクトがあります。 Fill colour 塗りつぶし色 No fill 塗りつぶしなし Fill colour: none 塗りつぶし色: なし diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e34803..64485e69e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2176,3 +2176,16 @@ key Esc Esc key Home Home key End End key Space Spacja + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} obiekt zaznaczony|{n} obiekty zaznaczone|{n} obiektów zaznaczonych + Drag the selection to move it. Esc clears the selection. Przeciągnij zaznaczenie, aby je przenieść. Esc anuluje zaznaczenie. + Shift-click to select several objects. Drag empty space to select an area. Klikaj z wciśniętym Shift, aby zaznaczyć kilka obiektów. Przeciągnij w pustym miejscu, aby zaznaczyć obszar. + Select at most 1000 objects at a time. Zaznacz najwyżej 1000 obiektów naraz. + Finish or discard the text being edited before selecting several objects. Zakończ lub odrzuć edycję tekstu przed zaznaczeniem kilku obiektów. + Move object Przenieś obiekt + Move objects Przenieś obiekty + Vertical text cannot be moved with this selection. Tekstu pionowego nie można przenieść razem z tym zaznaczeniem. + Text used as a clipping path cannot be moved with this selection. Tekstu używanego jako ścieżka przycinania nie można przenieść razem z tym zaznaczeniem. + These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia. + This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f4ad61fcb..2f09c576f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2149,6 +2149,19 @@ comment menu Edit Editar The field {name} no longer exists. O campo {name} não existe mais. A selected field has no widget on any page, so the fields can't be edited together. Um campo selecionado não tem widget em nenhuma página, então os campos não podem ser editados juntos. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, o maior tamanho em que podem ser desenhadas) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} objeto selecionado|{n} objetos selecionados + Drag the selection to move it. Esc clears the selection. Arraste a seleção para movê-la. Esc limpa a seleção. + Shift-click to select several objects. Drag empty space to select an area. Use Shift e clique para selecionar vários objetos. Arraste em um espaço vazio para selecionar uma área. + Select at most 1000 objects at a time. Selecione no máximo 1000 objetos por vez. + Finish or discard the text being edited before selecting several objects. Conclua ou descarte a edição do texto antes de selecionar vários objetos. + Move object Mover objeto + Move objects Mover objetos + Vertical text cannot be moved with this selection. O texto vertical não pode ser movido com esta seleção. + Text used as a clipping path cannot be moved with this selection. O texto usado como caminho de recorte não pode ser movido com esta seleção. + These objects could not be moved. Select them again and try a smaller move. Não foi possível mover estes objetos. Selecione-os novamente e tente um deslocamento menor. + This page contains objects that cannot be selected together. Esta página contém objetos que não podem ser selecionados juntos. Fill colour Cor de preenchimento No fill Sem preenchimento Fill colour: none Cor de preenchimento: nenhuma diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 28d80d71a..bbd02bb0f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2061,6 +2061,19 @@ The field {name} no longer exists. Поле {name} больше не существует. A selected field has no widget on any page, so the fields can't be edited together. У одного из выбранных полей нет виджета ни на одной странице, поэтому поля нельзя изменить вместе. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — с разрешением {dpi} dpi, максимальным, с каким их можно отрисовать) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected Выделен {n} объект|Выделено {n} объекта|Выделено {n} объектов + Drag the selection to move it. Esc clears the selection. Перетащите выделение, чтобы переместить его. Esc снимает выделение. + Shift-click to select several objects. Drag empty space to select an area. Щёлкните с Shift, чтобы выбрать несколько объектов. Протяните рамку на пустом месте, чтобы выделить область. + Select at most 1000 objects at a time. Выбирайте не более 1000 объектов одновременно. + Finish or discard the text being edited before selecting several objects. Завершите или отмените редактирование текста, прежде чем выбирать несколько объектов. + Move object Переместить объект + Move objects Переместить объекты + Vertical text cannot be moved with this selection. Вертикальный текст нельзя переместить вместе с этим выделением. + Text used as a clipping path cannot be moved with this selection. Текст, используемый как обтравочный контур, нельзя переместить вместе с этим выделением. + These objects could not be moved. Select them again and try a smaller move. Не удалось переместить эти объекты. Выберите их снова и попробуйте меньшее смещение. + This page contains objects that cannot be selected together. На этой странице есть объекты, которые нельзя выделить вместе. Fill colour Цвет заливки No fill Без заливки Fill colour: none Цвет заливки: нет diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index c33fbb6d2..6af421a11 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2080,6 +2080,18 @@ action wizard Start ప్రారంభించు A selected field has no widget on any page, so the fields can't be edited together. ఎంచుకున్న ఒక ఫీల్డ్‌కు ఏ పేజీలోనూ విడ్జెట్ లేదు, కాబట్టి ఫీల్డ్‌లను కలిపి సవరించలేము. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi వద్ద, అవి గీయగల అతిపెద్ద పరిమాణం) +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected {n} వస్తువు ఎంచుకోబడింది|{n} వస్తువులు ఎంచుకోబడ్డాయి + Drag the selection to move it. Esc clears the selection. ఎంపికను కదిలించడానికి లాగండి. Esc ఎంపికను తొలగిస్తుంది. + Shift-click to select several objects. Drag empty space to select an area. అనేక వస్తువులను ఎంచుకోవడానికి Shift నొక్కి క్లిక్ చేయండి. ప్రాంతాన్ని ఎంచుకోవడానికి ఖాళీ ప్రదేశంలో లాగండి. + Select at most 1000 objects at a time. ఒకేసారి గరిష్ఠంగా 1000 వస్తువులను ఎంచుకోండి. + Finish or discard the text being edited before selecting several objects. అనేక వస్తువులను ఎంచుకునే ముందు వచన సవరణను పూర్తి చేయండి లేదా విస్మరించండి. + Move object వస్తువును కదిలించు + Move objects వస్తువులను కదిలించు + Vertical text cannot be moved with this selection. ఈ ఎంపికతో నిలువు వచనాన్ని కదిలించలేరు. + Text used as a clipping path cannot be moved with this selection. క్లిప్పింగ్ పాత్‌గా ఉపయోగించిన వచనాన్ని ఈ ఎంపికతో కదిలించలేరు. + These objects could not be moved. Select them again and try a smaller move. ఈ వస్తువులను కదిలించలేకపోయాము. మళ్లీ ఎంచుకుని తక్కువ దూరం కదిలించడానికి ప్రయత్నించండి. + This page contains objects that cannot be selected together. ఈ పేజీలో కలిసి ఎంచుకోలేని వస్తువులు ఉన్నాయి. # Keyboard key names, distinct from command labels. key Ctrl Ctrl key Shift Shift diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6333695e5..1389e5506 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2171,6 +2171,19 @@ certificate Subject Власник сертифіката The field {name} no longer exists. Поле {name} більше не існує. A selected field has no widget on any page, so the fields can't be edited together. Одне з вибраних полів не має віджета на жодній сторінці, тому поля не можна редагувати разом. ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — з роздільністю {dpi} dpi, найбільшою, з якою їх можна відобразити) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected Виділено {n} об’єкт|Виділено {n} об’єкти|Виділено {n} об’єктів + Drag the selection to move it. Esc clears the selection. Перетягніть виділення, щоб перемістити його. Esc скасовує виділення. + Shift-click to select several objects. Drag empty space to select an area. Клацніть із Shift, щоб вибрати кілька об’єктів. Протягніть рамку на порожньому місці, щоб виділити область. + Select at most 1000 objects at a time. Вибирайте не більше 1000 об’єктів одночасно. + Finish or discard the text being edited before selecting several objects. Завершіть або скасуйте редагування тексту, перш ніж вибирати кілька об’єктів. + Move object Перемістити об’єкт + Move objects Перемістити об’єкти + Vertical text cannot be moved with this selection. Вертикальний текст не можна перемістити разом із цим виділенням. + Text used as a clipping path cannot be moved with this selection. Текст, що використовується як контур відсікання, не можна перемістити разом із цим виділенням. + These objects could not be moved. Select them again and try a smaller move. Не вдалося перемістити ці об’єкти. Виберіть їх знову та спробуйте менше зміщення. + This page contains objects that cannot be selected together. На цій сторінці є об’єкти, які не можна виділити разом. Fill colour Колір заливки No fill Без заливки Fill colour: none Колір заливки: немає diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 050d37493..f4e77deb2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2112,6 +2112,19 @@ The field {name} no longer exists. 字段 {name} 已不存在。 A selected field has no widget on any page, so the fields can't be edited together. 某个所选字段在任何页面上都没有控件,因此无法一起编辑这些字段。 ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 张以可绘制的最大尺寸 {dpi} dpi 导出) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected 已选择 {n} 个对象 + Drag the selection to move it. Esc clears the selection. 拖动选区即可移动。按 Esc 清除选择。 + Shift-click to select several objects. Drag empty space to select an area. 按住 Shift 单击可选择多个对象。拖动空白区域可框选。 + Select at most 1000 objects at a time. 一次最多选择 1000 个对象。 + Finish or discard the text being edited before selecting several objects. 请先完成或放弃正在编辑的文本,再选择多个对象。 + Move object 移动对象 + Move objects 移动多个对象 + Vertical text cannot be moved with this selection. 无法随此选区移动竖排文本。 + Text used as a clipping path cannot be moved with this selection. 无法随此选区移动用作剪切路径的文本。 + These objects could not be moved. Select them again and try a smaller move. 无法移动这些对象。请重新选择并尝试较小的移动距离。 + This page contains objects that cannot be selected together. 此页面包含无法一起选择的对象。 Fill colour 填充颜色 No fill 无填充 Fill colour: none 填充颜色:无 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 39248b7fa..7ad9a5679 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2034,6 +2034,19 @@ The field {name} no longer exists. 欄位 {name} 已不存在。 A selected field has no widget on any page, so the fields can't be edited together. 某個所選欄位在任何頁面上都沒有控制項,因此無法一起編輯這些欄位。 ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 張以可繪製的最大尺寸 {dpi} dpi 匯出) + +# Multi-object selection and movement (#844) +@plural {n} object selected|{n} objects selected 已選取 {n} 個物件 + Drag the selection to move it. Esc clears the selection. 拖曳選取範圍即可移動。按 Esc 清除選取。 + Shift-click to select several objects. Drag empty space to select an area. 按住 Shift 點選可選取多個物件。拖曳空白區域可框選。 + Select at most 1000 objects at a time. 一次最多選取 1000 個物件。 + Finish or discard the text being edited before selecting several objects. 請先完成或放棄正在編輯的文字,再選取多個物件。 + Move object 移動物件 + Move objects 移動多個物件 + Vertical text cannot be moved with this selection. 無法隨此選取範圍移動直排文字。 + Text used as a clipping path cannot be moved with this selection. 無法隨此選取範圍移動用作裁切路徑的文字。 + These objects could not be moved. Select them again and try a smaller move. 無法移動這些物件。請重新選取並嘗試較小的移動距離。 + This page contains objects that cannot be selected together. 此頁面包含無法一起選取的物件。 Fill colour 填充顏色 No fill 無填充 Fill colour: none 填充顏色:無 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 62b4c1be8..479e5b6f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,7 @@ pub mod marks { } mod content_ui; mod link_ui; +mod object_ui; pub use create_ui::Clip; pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; @@ -1984,10 +1985,11 @@ impl eframe::App for PdfCraftApp { // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); self.shortcuts(ctx); - // Scrolling is transient: never resume after changing tabs, opening a modal/palette, - // or returning to a window that lost focus. + // Scrolling and group-drag previews are transient: never resume after changing tabs, + // opening a modal/palette, or returning to a window that lost focus. let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused); for (index, view) in self.views.iter_mut().enumerate() { + view.objects.set_input_blocked(blocked || self.active != Some(index)); if blocked || self.active != Some(index) { view.auto_scroll.cancel(); } diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs new file mode 100644 index 000000000..feb81c8cf --- /dev/null +++ b/crates/ui-egui/src/object_ui.rs @@ -0,0 +1,363 @@ +//! Same-page mixed selection in Edit a PDF. Source references belong to one document +//! generation; a drag previews geometry and commits one engine edit on release. + +use std::collections::HashMap; + +use egui::{Color32, Pos2, Rect, Stroke}; +use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +use pdfcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; + +const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +#[derive(Default)] +pub(crate) struct ObjectSelection { + generation: Option, + cache: HashMap, String>>, + page: Option, + selected: Vec, + gesture: Option, + /// Expected geometry after our own edit, for remapping indexes that grouping can change. + pending: Option>, + pub notice: Option, + pub hold_editor: bool, + blocked_page: Option, + input_blocked: bool, +} + +#[derive(Clone, Copy)] +struct Gesture { + start: Pos2, + now: Pos2, + marquee: bool, + additive: bool, +} + +impl ObjectSelection { + pub fn count(&self) -> usize { + self.selected.len() + } + + /// A preview belongs to the active, focused document. Returning from another tab or a + /// modal must not turn a release that happened elsewhere into a document edit. + pub fn set_input_blocked(&mut self, blocked: bool) { + self.input_blocked = blocked; + if blocked { + self.gesture = None; + self.blocked_page = None; + } + } + + fn clear(&mut self) { + self.page = None; + self.selected.clear(); + self.gesture = None; + self.pending = None; + self.blocked_page = None; + } + + pub fn prepare(&mut self, doc: &Document, enabled: bool, ctx: &egui::Context) { + self.hold_editor = false; + if !enabled { + self.clear(); + return; + } + let generation = doc.edit_generation(); + if self.generation != Some(generation) { + self.cache.clear(); + self.gesture = None; + let expected = self.pending.take(); + let remapped = self.page.zip(expected).and_then(|(page, expected)| { + let inventory = doc.editable_objects(page).ok()?; + let mut out = Vec::new(); + for old in expected { + let mut matches = inventory.iter().filter(|o| { + o.target.kind == old.target.kind + && o.content_type == old.content_type + && o.text == old.text + && o.rect.iter().zip(old.rect).all(|(a, b)| (a - b).abs() < 0.01) + }); + let object = matches.next()?; + if matches.next().is_some() || out.iter().any(|o: &EditableObject| o.target == object.target) { + return None; + } + out.push(object.clone()); + } + Some(out) + }); + if let Some(objects) = remapped { + self.selected = objects; + } else { + self.clear(); + } + self.generation = Some(generation); + } else { + // A refused edit keeps the generation and the original selection. + self.pending = None; + } + if self.page.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + self.clear(); + } + } + + fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { + if self.cache.len() >= 8 && !self.cache.contains_key(&page) { + self.cache.clear(); + } + self.cache.entry(page).or_insert_with(|| doc.editable_objects(page)).clone() + } +} + +fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) -> Rect { + xf.user_rect(info, page, object.rect.map(|v| v as f32)) +} + +fn modified(ui: &egui::Ui) -> bool { + ui.input(|i| { + let modifiers = i + .events + .iter() + .find_map(|e| match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers); + modifiers.shift || modifiers.ctrl || modifiers.command + }) +} + +fn single(view: &DocView, page: usize) -> Option { + if let Some((_, index)) = view.content.selected.filter(|(p, _)| *p == page) { + Some(ObjectTarget { kind: ObjectKind::Added, index }) + } else if let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) { + Some(ObjectTarget { kind: ObjectKind::Image, index: s.index }) + } else { + view.line_editor.as_ref().filter(|e| e.page == page).map(|e| ObjectTarget { kind: ObjectKind::Text, index: e.block }) + } +} + +fn leave_single(view: &mut DocView) { + view.content.selected = None; + view.image_selection = None; + view.line_editor = None; + view.block_drag = None; +} + +/// Returns true while group selection owns the interaction. Ordinary clicks still reach the +/// single-object text/resize tools. A text draft is finished or discarded through its editor. +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { + if view.objects.input_blocked { + return true; + } + if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { + let blocked = view.objects.blocked_page == Some(page); + let modifier_press = ui.input(|i| i.pointer.primary_pressed()) + && modified(ui) + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + if blocked || modifier_press { + view.objects.hold_editor = true; + view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); + if modifier_press { + view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); + } + return true; + } + return false; + } + let mut selection = std::mem::take(&mut view.objects); + let result = input(&mut selection, ui, resp, xf, page, doc, view); + view.objects = selection; + result +} + +#[allow(clippy::too_many_arguments)] +fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { + let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down())); + let info = &doc.info; + if s.page == Some(page) + && let Some(mut gesture) = s.gesture + { + if let Some(p) = pointer { + gesture.now = p; + } + if !down { + if !gesture.marquee && gesture.start.distance(gesture.now) <= 3.0 && resp.double_clicked() { + s.clear(); + return false; + } + s.gesture = None; + if gesture.marquee { + let area = Rect::from_two_pos(gesture.start, gesture.now).intersect(xf.rect); + if !gesture.additive { + s.selected.clear(); + } + if area.width() > 3.0 + && area.height() > 3.0 + && let Ok(objects) = s.inventory(doc, page) + { + let candidates: Vec<_> = objects.into_iter().filter(|o| area.intersects(screen(xf, info, page, o))).collect(); + let mut selected = s.selected.clone(); + for object in candidates { + if !selected.iter().any(|o| o.target == object.target) { + selected.push(object); + } + } + if selected.len() <= MAX_MOVE_OBJECTS { + s.selected = selected; + } else { + s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); + } + } + if s.selected.is_empty() { + s.clear(); + } + } else if gesture.start.distance(gesture.now) > 3.0 && view.pending_edit.is_none() { + let (a, b) = (xf.screen_to_view(gesture.start), xf.screen_to_view(gesture.now)); + match doc.object_move_offset(page, [f64::from(b.0 - a.0), f64::from(b.1 - a.1)]) { + Ok(offset) => { + let mut expected = s.selected.clone(); + for object in &mut expected { + object.rect = + [object.rect[0] + offset[0], object.rect[1] + offset[1], object.rect[2] + offset[0], object.rect[3] + offset[1]]; + } + s.pending = Some(expected); + view.pending_edit = Some(Edit::MoveObjects { page, objects: s.selected.iter().map(|o| o.target).collect(), offset }); + } + Err(_) => s.notice = Some(tl!("These objects could not be moved. Select them again and try a smaller move.").into()), + } + } + } else { + s.gesture = Some(gesture); + } + ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Grabbing }); + return true; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { + return s.page == Some(page) && !s.selected.is_empty(); + }; + if !pressed { + if s.page == Some(page) && !s.selected.is_empty() { + let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); + if union.contains(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + return true; + } + return false; + } + let objects = match s.inventory(doc, page) { + Ok(objects) => objects, + Err(_) => { + if modified(ui) { + s.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + return true; + } + return false; + } + }; + let additive = modified(ui); + // A page-sized background image must not hide editable labels or added notes. + // Within each kind, prefer the last object as the ordinary single-item tools do. + let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] + .into_iter() + .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))); + if additive { + if s.page != Some(page) { + s.clear(); + s.page = Some(page); + if let Some(target) = single(view, page) + && let Some(object) = objects.iter().find(|o| o.target == target) + { + s.selected.push(object.clone()); + } + } + if let Some(object) = hit { + if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { + s.selected.remove(index); + } else if s.selected.len() < MAX_MOVE_OBJECTS { + s.selected.push(object.clone()); + } else { + s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); + } + } else { + s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }); + } + leave_single(view); + view.current = page; + return true; + } + if s.page == Some(page) && !s.selected.is_empty() { + let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); + if union.contains(p) && !resp.double_clicked() { + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + return true; + } + } + s.clear(); + if hit.is_some() { + return false; + } + leave_single(view); + s.page = Some(page); + s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: false }); + view.current = page; + true +} + +pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { + if selection.page != Some(page) { + return; + } + let delta = selection.gesture.filter(|g| !g.marquee).map_or(egui::Vec2::ZERO, |g| g.now - g.start); + let mut union = Rect::NOTHING; + for object in &selection.selected { + let rect = screen(xf, info, page, object).translate(delta); + painter.rect_filled(rect, 0.0, BLUE.gamma_multiply(0.05)); + painter.rect_stroke(rect, 0.0, Stroke::new(1.5, BLUE), egui::StrokeKind::Outside); + union = union.union(rect); + } + if selection.selected.len() > 1 { + painter.rect_stroke(union.expand(4.0), 0.0, Stroke::new(1.0, BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + if let Some(g) = selection.gesture.filter(|g| g.marquee) { + let area = Rect::from_two_pos(g.start, g.now).intersect(xf.rect); + painter.rect_filled(area, 0.0, BLUE.gamma_multiply(0.08)); + painter.rect_stroke(area, 0.0, Stroke::new(1.0, BLUE), egui::StrokeKind::Outside); + } +} + +/// Keep the UI refusal actionable and translated; headless callers retain the detailed error. +pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { + let detail = error.to_string(); + match detail.as_str() { + "vertical text cannot be moved in a group" => tl!("Vertical text cannot be moved with this selection.").into(), + "text used as a clipping path cannot be moved in a group" => tl!("Text used as a clipping path cannot be moved with this selection.").into(), + _ => tl!("These objects could not be moved. Select them again and try a smaller move.").into(), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn selection_labels_and_refusals_are_translated_in_every_catalog() { + for language in crate::i18n::LANGUAGES.iter().filter(|l| !l.source.is_empty()) { + let lang = crate::i18n::Lang::from_code(language.code).unwrap(); + for label in [ + "Drag the selection to move it. Esc clears the selection.", + "Shift-click to select several objects. Drag empty space to select an area.", + "Select at most 1000 objects at a time.", + "Finish or discard the text being edited before selecting several objects.", + "Move object", + "Move objects", + "Vertical text cannot be moved with this selection.", + "Text used as a clipping path cannot be moved with this selection.", + "These objects could not be moved. Select them again and try a smaller move.", + "This page contains objects that cannot be selected together.", + ] { + assert_ne!(crate::i18n::tr(lang, label), label, "{}: {label}", language.code); + } + assert_ne!(crate::i18n::trn(lang, 2, "{n} object selected", "{n} objects selected"), "2 objects selected", "{}", language.code); + } + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a76895dfe..b4127ec4a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -153,6 +153,16 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } // Edit a PDF shows Format text at the top while text is selected or being added. if g.id == "edit" { + if app.quick_tool == crate::QuickTool::EditText { + let count = app.active_ids().map_or(0, |(i, _)| app.views[i].objects.count()); + if count > 0 { + ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); + ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); + } else { + ui.label(tl!("Shift-click to select several objects. Drag empty space to select an area.")); + } + ui.add_space(6.0); + } format_section(app, ui, t); } let mut run = None; diff --git a/crates/ui-egui/tests/object_selection.rs b/crates/ui-egui/tests/object_selection.rs new file mode 100644 index 000000000..343483378 --- /dev/null +++ b/crates/ui-egui/tests/object_selection.rs @@ -0,0 +1,383 @@ +//! Multi-object editing through the real shell. Synthetic artwork and standard-font text only. +use egui::{Key, Modifiers, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::{AddedText, Edit, EditableObject}; +use pdfcraft_ui_egui::PdfCraftApp; + +fn fixture(clipping: bool) -> Vec { + fixture_with_background(clipping, false) +} + +fn fixture_with_background(clipping: bool, background: bool) -> Vec { + let stream = |body: &str, dict: &str| format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len()); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Figure 6 0 R >> >> >>" + .into(), + stream( + &format!( + "{}q BT {} Tr /F1 18 Tf 40 430 Td (Group headline) Tj ET Q q 1 0 0 1 40 340 cm /Figure Do Q BT /F1 12 Tf 240 90 Td (Keep here) Tj ET", + if background { "q /Background Do Q " } else { "" }, + if clipping { 4 } else { 0 } + ), + "", + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + stream("1 0.2 0.1 rg 0 0 60 30 re f", "/Type /XObject /Subtype /Form /BBox [0 0 60 30]"), + ]; + if background { + objects[2] = objects[2].replace("/Figure 6 0 R", "/Figure 6 0 R /Background 7 0 R"); + objects.push(stream("1 1 1 rg 0 0 400 500 re f", "/Type /XObject /Subtype /Form /BBox [0 0 400 500]")); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn harness() -> Harness<'static, PdfCraftApp> { + harness_with(false) +} + +fn harness_with(clipping: bool) -> Harness<'static, PdfCraftApp> { + harness_document(clipping, false) +} + +fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000); + app.set_option("language", "en").unwrap(); + app.open_bytes("group.pdf", None, fixture_with_background(clipping, background)).unwrap(); + let id = app.views[0].id; + app.session + .apply( + id, + Edit::AddText { page: 0, text: AddedText { text: "Added note".into(), rect: [130.0, 340.0, 230.0, 370.0], ..Default::default() } }, + ) + .unwrap(); + let saved = app.session.save_bytes(id).unwrap(); + app.session.mark_saved(id, saved, None).unwrap(); + app.set_option("zoom", "100").unwrap(); + assert!(app.execute("edit.edit_text")); + app + }); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h +} + +fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).unwrap(); + r.min + egui::vec2(x, y) * (r.width() / 400.0) +} +fn objects(h: &Harness<'static, PdfCraftApp>) -> Vec { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().editable_objects(0).unwrap() +} +fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2, mods: Modifiers) { + h.hover_at(p); + h.run_steps(2); + for pressed in [true, false] { + h.event_modifiers(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: mods }, mods); + } + h.run_steps(4); +} +fn marquee(h: &mut Harness<'static, PdfCraftApp>) { + let (a, b) = (at(h, 25.0, 45.0), at(h, 235.0, 175.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); + h.get_by_label("3 objects selected"); +} +fn drag(h: &mut Harness<'static, PdfCraftApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); +} + +#[test] +fn marquee_and_mixed_group_drag_move_once_and_undo_together() { + let mut h = harness(); + let before = objects(&h); + marquee(&mut h); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0)); + drag(&mut h, a, b); + let after = objects(&h); + for old in &before { + let new = after.iter().find(|o| o.target == old.target).unwrap(); + let shift = if old.text.as_deref() == Some("Keep here") { [0.0, 0.0] } else { [25.0, -30.0] }; + for i in 0..4 { + assert!((new.rect[i] - old.rect[i] - shift[i % 2]).abs() < 0.01, "{old:?} -> {new:?}"); + } + } + h.get_by_label("3 objects selected"); + let app = h.state(); + assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Move objects")); + if let Ok(path) = std::env::var("PDFCRAFT_GROUP_SHOT") { + h.render().unwrap().save(path).unwrap(); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(objects(&h), before); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_eq!(objects(&h), after); +} + +#[test] +fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() { + let mut h = harness(); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + assert!(h.state().views[0].line_editor.is_some()); + let p = at(&h, 60.0, 145.0); + click(&mut h, p, Modifiers::SHIFT); + h.get_by_label("2 objects selected"); + assert!(h.state().views[0].line_editor.is_none()); + let p = at(&h, 155.0, 140.0); + click(&mut h, p, Modifiers::COMMAND); + h.get_by_label("3 objects selected"); + let p = at(&h, 60.0, 145.0); + click(&mut h, p, Modifiers::CTRL); + h.get_by_label("2 objects selected"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + h.key_press(Key::Escape); + h.run_steps(3); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); +} + +#[test] +fn escaping_a_group_drag_and_switching_tools_do_not_edit() { + let mut h = harness(); + let before = objects(&h); + marquee(&mut h); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + assert_eq!(objects(&h), before, "only a preview while held"); + h.key_press(Key::Escape); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); + assert_eq!(objects(&h), before); + marquee(&mut h); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(4); + assert_eq!(objects(&h), before); +} + +#[test] +fn a_changed_text_editor_is_kept_when_group_selection_is_requested() { + let mut h = harness(); + let before = objects(&h); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into(); + let p = at(&h, 60.0, 145.0); + click(&mut h, p, Modifiers::SHIFT); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "A draft still being edited"); + assert_eq!(objects(&h), before); +} + +#[test] +fn a_document_change_invalidates_old_selection_before_the_next_drag() { + let mut h = harness(); + marquee(&mut h); + let id = h.state().views[0].id; + h.state_mut() + .session + .apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } }) + .unwrap(); + h.run_steps(4); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert_eq!(objects(&h).len(), 5); +} + +#[test] +fn refusing_one_object_keeps_the_whole_group_and_its_selection() { + let mut h = harness_with(true); + let before = objects(&h); + marquee(&mut h); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0)); + drag(&mut h, a, b); + assert_eq!(objects(&h), before); + h.get_by_label("3 objects selected"); + assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection.")); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); +} + +#[test] +fn a_group_drag_released_beyond_the_page_finishes_once() { + let mut h = harness(); + let before = objects(&h); + marquee(&mut h); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 415.0, 100.0)); + drag(&mut h, a, b); + let after = objects(&h); + for (old, new) in before.iter().zip(&after) { + let delta = if old.text.as_deref() == Some("Keep here") { 0.0 } else { 360.0 }; + assert!((new.rect[0] - old.rect[0] - delta).abs() < 0.01); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(objects(&h), before); +} + +#[test] +fn group_selection_can_be_extended_by_an_additive_marquee() { + let mut h = harness(); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::SHIFT); + h.get_by_label("1 object selected"); + let (a, b) = (at(&h, 25.0, 120.0), at(&h, 235.0, 175.0)); + h.hover_at(a); + h.run_steps(2); + h.event_modifiers( + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }, + Modifiers::SHIFT, + ); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); + h.get_by_label("3 objects selected"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); +} + +#[test] +fn group_drag_respects_document_rotation_and_view_rotation() { + for document_rotation in [0, 90, 180, 270] { + let mut h = harness(); + assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: document_rotation })); + h.state_mut().views[0].rotate_view(true); + h.run_steps(4); + let before = objects(&h); + let targets: Vec<_> = before.iter().filter(|o| o.text.as_deref() != Some("Keep here")).map(|o| o.target).collect(); + for target in &targets { + let app = h.state(); + let doc = app.session.get(app.views[0].id).unwrap(); + let o = before.iter().find(|o| o.target == *target).unwrap(); + let p = pdfcraft_ui_egui::canvas::PageXform { + rect: app.views[0].page_screen_rect(0).unwrap(), + rot: 90, + pw: doc.info.pages[0].width, + ph: doc.info.pages[0].height, + } + .user_rect(&doc.info, 0, o.rect.map(|v| v as f32)) + .center(); + click(&mut h, p, Modifiers::SHIFT); + } + h.get_by_label("3 objects selected"); + let app = h.state(); + let doc = app.session.get(app.views[0].id).unwrap(); + let xf = pdfcraft_ui_egui::canvas::PageXform { + rect: app.views[0].page_screen_rect(0).unwrap(), + rot: 90, + pw: doc.info.pages[0].width, + ph: doc.info.pages[0].height, + }; + let start = + xf.user_rect(&doc.info, 0, before.iter().find(|o| o.text.as_deref() == Some("Group headline")).unwrap().rect.map(|v| v as f32)).center(); + let end = start + egui::vec2(21.0, 33.0); + let from = xf.screen_to_view(start); + let to = xf.screen_to_view(end); + let delta = doc.object_move_offset(0, [f64::from(to.0 - from.0), f64::from(to.1 - from.1)]).unwrap(); + drag(&mut h, start, end); + for (old, new) in before.iter().zip(objects(&h)) { + let expected = if targets.contains(&old.target) { delta } else { [0.0, 0.0] }; + for i in 0..4 { + assert!((new.rect[i] - old.rect[i] - expected[i % 2]).abs() < 0.01, "rotation {document_rotation}"); + } + } + } +} + +#[test] +fn group_drags_cancel_on_focus_loss_tab_switch_home_and_modal_dialogs() { + for case in 0..5 { + let mut h = harness(); + let before = objects(&h); + marquee(&mut h); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + match case { + 0 => h.input_mut().focused = false, + 1 => { + h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap(); + } + 2 => h.state_mut().active = None, + 3 => h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About), + _ => h.state_mut().request_document_url("https://example.invalid/", pdfcraft_ui_egui::LinkOrigin::Link), + } + h.run_steps(2); + h.drop_at(b); + h.run_steps(2); + h.input_mut().focused = true; + h.state_mut().active = Some(0); + h.state_mut().dialog = None; + h.state_mut().pending_link = None; + h.run_steps(4); + assert_eq!(objects(&h), before, "cancelled gesture case {case}"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "case {case}"); + h.get_by_label("3 objects selected"); + } +} + +#[test] +fn modifier_selection_picks_foreground_text_and_added_items_over_background_artwork() { + let mut h = harness_document(false, true); + let before = objects(&h); + assert_eq!(before.len(), 5); + for (x, y) in [(55.0, 70.0), (150.0, 135.0), (60.0, 145.0)] { + let p = at(&h, x, y); + click(&mut h, p, Modifiers::SHIFT); + } + h.get_by_label("3 objects selected"); + let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0)); + drag(&mut h, a, b); + let after = objects(&h); + for old in before { + let new = after.iter().find(|o| o.target == old.target).unwrap(); + let stationary = old.text.as_deref() == Some("Keep here") || (old.target.kind == pdfcraft_engine::ObjectKind::Image && old.target.index == 0); + let shift = if stationary { [0.0, 0.0] } else { [25.0, -30.0] }; + assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}"); + } +} diff --git a/docs/editing-multiple-objects.md b/docs/editing-multiple-objects.md new file mode 100644 index 000000000..2eb7d84a5 --- /dev/null +++ b/docs/editing-multiple-objects.md @@ -0,0 +1,87 @@ +# Move several objects together + +In **Edit a PDF → Edit text & images**, select several items on the same page: + +- **Shift-click**, **Ctrl-click**, or **Command-click** toggles an item. An ordinary selected + paragraph, image, or added item becomes the first member when you extend its selection. +- Drag through empty page space to draw a selection rectangle. Items intersecting the rectangle + are selected; hold one of those modifiers to add to the selection. +- Drag the selection to move all its items by the same distance. Blue outlines preview the move; + releasing commits it as **one undo step**. Escape cancels the gesture or clears the selection. +- Switching tabs, opening Home or a modal dialog, or losing window focus cancels a drag preview. + Returning to the document keeps the selection without committing a release from elsewhere. +- Modifier-clicks prefer added items and text over background artwork, so labels remain selectable. +- Clear the selection or double-click an item to return to ordinary text editing and image resizing. + Finish or discard a text draft before starting a multi-selection. + +The supported items are the existing paragraph boxes, raster Image XObjects, whole Form artwork, +and text/images added by PdfCraft. A Form remains one item, including its nested artwork and +resources. Added items appear once in the selection inventory. Selections stay on one page and +contain at most 1,000 items; pages with more than 10,000 recognised objects are refused for group +selection. Content streams must decode completely within 64 MiB each and 128 MiB per page, with +a 1,024-level graphics-state limit and 100,000 content operations. These limits apply to both the +original page and the moved result; unreadable or missing streams are refused. Moving between pages, +arbitrary paths, inline images, group resizing, align/distribute, +and vertical or clipping text are outside this operation's scope. A refusal changes nothing. + +Added streams move by changing their outer placement, preserving drawing bytes, fonts, crop paths, +and image transforms even if the page was rotated after the item was added. Their original local +coordinate basis is retained in `/PCAdded/MoveOrigin` so metadata and source placement agree after +repeated moves. Ordinary content updates replace that stream and reset the basis. + +Existing text moves without retyping, font substitution, or reflow: its original encoded glyph +strings and kerning are replayed with temporary numeric `TJ` adjustments and text rise. The reader +performs the original glyph advances, so following unselected text keeps its position. Paragraph +indices can change when detection groups lines differently after a move. The UI remaps its own +selection only when the new geometry matches uniquely; other document edits clear stale selection. + +## Engine + +`Document::editable_objects(page)` returns a mixed inventory with zero-based `ObjectTarget` +references and user-space rectangles. Apply `Edit::MoveObjects { page, objects, offset }` through +`Session`. Targets resolve together before mutation. Permission checks, dirty state, history, +incremental saving, and refreshing use the same transaction as other edits. + +`Document::object_move_offset(page, [dx, dy])` converts a displayed-page vector (points, right/down) +to the user-space vector required by the edit, accounting for the crop box, `/Rotate`, and `/UserUnit`. +A frontend with an additional view rotation converts its pointer positions to displayed page space +first. Duplicate, missing, non-finite, oversized, or unsupported targets fail atomically. Engine callers +re-list after changes; the automation generation guard detects stale references before the edit. + +## CLI and MCP + +Both frontends use the same tools. `object_list` returns a document `generation` and items with +`kind` (`added`, `text`, or `image`), 1-based `index`, `type`, displayed `rect`, and optional `text`. +Treat those references as belonging to that generation and pass it back when moving: + +```json +[ + {"tool":"doc_open","args":{"path":"input.pdf"}}, + {"tool":"object_list","args":{"doc":1,"page":1}} +] +``` + +After choosing references from the returned list, use a move call like this in the same MCP +session. For a CLI script, include `doc_open` in that script before the move: + +```json +[ + {"tool":"object_move","args":{ + "doc":1,"page":1,"generation":0, + "objects":[{"kind":"text","index":1},{"kind":"image","index":1}], + "offset":[24,18] + }}, + {"tool":"doc_save","args":{"doc":1,"path":"moved.pdf"}} +] +``` + +Use the actual generation and references from your document, rather than assuming these example +values. `offset` is in displayed points: positive x moves right, positive y moves down. The optional +generation guard rejects a changed document before interpreting references. Re-list after any edit, +undo, redo, or save; there is no claim that an index persists across generations. Run a script with +`pdfcraft-cli run --script steps.json --root DIR` or call these tools through the opt-in MCP server. + +Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms, +rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history, +rendering and save/reopen), `crates/automation/tests/automation.rs` (generation guards and hostile +references), and `crates/ui-egui/tests/object_selection.rs` (real-shell selection and gestures). diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1d02aac64..e5a69e049 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2139,11 +2139,11 @@ title = "Edit object tool for any page object" area = "C" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.edit_text"] +tools = ["object_list", "object_move"] +evidence = ["crates/ui-egui/tests/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"] +notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." [[feature]] id = "edit.object-properties"