diff --git a/ROADMAP.md b/ROADMAP.md index 570b73e63d..3643bb0485 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,6 +41,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -190,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. +- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged. - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. diff --git a/crates/automation/README.md b/crates/automation/README.md index 669ea61463..87111b2eaf 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -15,6 +15,12 @@ tools() -> Vec // name, title, descrip mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JSON-RPC 2.0 ``` +`content_update.rotate` rotates an added image counter-clockwise by an integer multiple of +90 degrees. Large positive and negative signed 64-bit angles are normalized before combining +them with the image's current orientation. Full turns retain its appearance, rectangle, crop +and flips. Angles that are not multiples of 90 are refused without changing the document or +its undo/redo history. + `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index faefc5fa7c..aa8615f29e 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -171,7 +171,8 @@ impl Automation { if deg.rem_euclid(90) != 0 { return Err(bad("rotate must be a multiple of 90")); } - i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8; + // Reduce untrusted degrees before adding the current quarter turns. + i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8; } if a.opt_bool("flip_h")? == Some(true) { i.flip_h = !i.flip_h; diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 4653bc087e..a718602a01 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3734,6 +3734,103 @@ fn content_index_out_of_range_is_an_error_not_a_panic() { assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); } +fn added_rotation_image(dir: &Path) { + let mut png = Vec::new(); + let mut enc = png::Encoder::new(&mut png, 2, 1); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255, 0, 0, 0, 0, 255]).unwrap(); + std::fs::write(dir.join("rotation.png"), png).unwrap(); +} + +#[test] +fn large_added_image_rotations_keep_orientation_and_undo() { + let dir = workdir("large-added-rotation"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let image = |a: &Automation, doc| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("added image") }; + image.clone() + }; + let largest = i64::MAX / 90 * 90; + let smallest = i64::MIN / 90 * 90; + for initial in [1, 0, 2, 3] { + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": initial * 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let before = image(&a, doc); + for degrees in [largest, largest - 90, largest - 180, smallest, smallest + 90, smallest + 180, -450, -90, 0, 90, 450] { + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })); + let expected = pdfcraft_engine::AddedImage { rotation: (initial + (degrees / 90).rem_euclid(4)).rem_euclid(4) as u8, ..before.clone() }; + assert_eq!(image(&a, doc), expected, "initial quarter turn {initial}, degrees {degrees}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), before, "Undo restores the image and its properties"); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), expected, "Redo restores the normalized orientation"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": format!("initial-{initial}.pdf") })); + let state = |a: &Automation| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + (d.bytes.clone(), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned)) + }; + let saved = state(&a); + assert!(!saved.1 && saved.4.is_some(), "a clean document with a redo step"); + for degrees in [i64::MIN, i64::MAX, -1, 1, 45] { + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })), + Err(ToolError::InvalidArgs(_)) + )); + assert_eq!(state(&a), saved, "invalid degrees {degrees} leave bytes and history alone"); + } + } +} + +#[cfg(feature = "mcp")] +#[test] +fn large_added_image_rotations_round_trip_through_mcp() { + let dir = workdir("large-added-rotation-mcp"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let mut server = McpServer::new(a); + let render = json!({ "name": "page_render", "arguments": { "doc": doc, "page": 1, "dpi": 72 } }); + let before = rpc(&mut server, 1, "tools/call", render.clone()); + let update = rpc( + &mut server, + 2, + "tools/call", + json!({ "name": "content_update", "arguments": { + "doc": doc, "page": 1, "index": 1, "rotate": i64::MAX / 360 * 360 + } }), + ); + assert_eq!(update["result"]["isError"], false, "{update}"); + let after = rpc(&mut server, 3, "tools/call", render.clone()); + assert_eq!(after["result"]["content"], before["result"]["content"], "full turns retain the rendered orientation"); + let saved = rpc(&mut server, 4, "tools/call", json!({ "name": "doc_save", "arguments": { "doc": doc, "path": "rotated.pdf" } })); + assert_eq!(saved["result"]["isError"], false, "{saved}"); + let mut reopened = auto(&dir); + let doc = ok(&mut reopened, "doc_open", json!({ "path": "rotated.pdf" }))["doc"].as_u64().unwrap(); + let d = reopened.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("saved image") }; + assert_eq!(image.rotation, 1); + let png = reopened.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &png[0] else { panic!("rendered PNG") }; + assert_eq!(after["result"]["content"][0]["data"], base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data)); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index 201cc1d6c3..07a50c8488 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,6 +48,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -139,7 +140,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index de78572334..62511f5300 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2105,9 +2105,9 @@ tier = "P0" milestone = "M7" status = "shipped" commands = ["edit.edit_text"] -tools = ["image_edit"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools"] -notes = "existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too" +tools = ["image_edit", "content_update"] +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/automation/tests/automation.rs::large_added_image_rotations_keep_orientation_and_undo", "crates/automation/tests/automation.rs::large_added_image_rotations_round_trip_through_mcp"] +notes = "Existing raster images and grouped Form artwork: rotate either way about the centre, flip horizontally or vertically (a q/cm/Q wrapper); Forms stay intact as groups; added images too. Added-image content_update rotation accepts positive or negative integer multiples of 90 degrees; reducing the angle before addition prevents overflow at signed 64-bit limits and preserves the current orientation through full turns, Undo/Redo and save/reopen. Added-image rotations remain limited to 90-degree steps." [[feature]] id = "edit.image-move-resize"