From 905e9abf6f891b9dc8eeee9df3a65bb402bf1441 Mon Sep 17 00:00:00 2001 From: enriquemelzer-bit Date: Tue, 10 Nov 2026 11:50:16 -0300 Subject: [PATCH 1/7] fix(forms): ignore print requests from automatic field scripts (#4166) * fix(forms): ignore print requests from automatic field scripts * docs(contributors): opt in to GitHub profile name sync --------- Co-authored-by: enriquemelzer-bit <319226743+enriquemelzer-bit@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 13 ++++++ contributors/people.toml | 3 ++ crates/automation/src/tests.rs | 23 ++++++++++ crates/engine/README.md | 5 ++ crates/engine/src/js.rs | 9 ++-- crates/engine/src/tests.rs | 84 ++++++++++++++++++++++++++++++++++ docs/roadmap.md | 11 ++++- parity/acrobat-features.toml | 4 +- 8 files changed, 146 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7dfc00e76e..80b3687175 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged) > **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) > > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) @@ -39,6 +40,10 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged) +> +> +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) @@ -244,6 +249,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. +- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. + +- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged. - **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices. Synthetic render and save/reopen regressions cover the change; estimates unchanged. @@ -340,6 +348,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. +- **2026-10-22 (M6, #4088):** Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot open the print dialog; ignored requests are reported in the JavaScript console. Explicit button and console printing remain available, including when they trigger automatic calculations. Engine and headless automation regressions cover the boundary. Estimates unchanged. + - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged. - **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. @@ -836,6 +846,9 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. + +**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged. **2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients. Synthetic rendering and save/reopen regressions cover the change. Estimates unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index a22c884ccb..06c98f6464 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,6 @@ real_name = "Helen Kwok" real_name = "Valentin Klein" display_name = "vangel76" sync_github_name = false + +[people.enriquemelzer-bit] +sync_github_name = true diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 1b857b6c29..1f3a13cbf0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -38,6 +38,29 @@ fn search_document(a: &mut Automation, pages: usize) -> DocId { a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() } +#[test] +fn form_fill_cannot_print_but_js_run_can() { + let mut a = Automation::new(); + let doc = search_document(&mut a, 1); + json(&mut a, "form_add_field", json!({ "doc": doc.0, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "Qty" })); + json( + &mut a, + "form_set_script", + json!({ "doc": doc.0, "field": "Qty", "event": "validate", "script": "this.print(); console.println('validated');" }), + ); + json(&mut a, "form_fill", json!({ "doc": doc.0, "values": { "Qty": "4" } })); + let output = a.session.take_js_output(doc); + assert!(output.requests.is_empty(), "{output:?}"); + assert!(output.console.contains(&"validated".to_string()), "{output:?}"); + assert!(output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{output:?}"); + assert!(output.errors.is_empty(), "{output:?}"); + let fields = json(&mut a, "form_fields", json!({ "doc": doc.0 })); + let qty = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Qty").unwrap(); + assert_eq!(qty["value"], "4"); + let explicit = json(&mut a, "js_run", json!({ "doc": doc.0, "script": "this.print();" })); + assert_eq!(explicit["requests"], json!([{ "print": true }])); +} + #[test] fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { let mut a = Automation::new(); diff --git a/crates/engine/README.md b/crates/engine/README.md index 3962afc50d..25216ba46a 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -82,6 +82,11 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`). | `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible | | `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names | +AcroForm Keystroke, Validate, Calculate and Format scripts run automatically. Their Print, +ShowPanel and SaveAs requests are ignored with a JavaScript console diagnostic; field values, +alerts and other requests are retained. Explicit button and console scripts can still request +printing (#4088). + ## Not done here - **Rendering is still borrowed.** `pdfcraft-render` wraps `hayro` and inspects through `lopdf` diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d2b698a370..1d46f97786 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -81,18 +81,21 @@ impl JsOutput { } /// Adds what a field's own script produced: its alerts, console lines, errors and requests, - /// except a request to show a side panel, open Save As, or open or save an attached file, - /// which is noted on the console and dropped. + /// except a request to print, show a side panel, open Save As, or open or save an attached + /// file, which is noted on the console and dropped. fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel, open Save As, or open or save an attached file. + // button's or the console's may print, show or hide a side panel, open Save As, or open + // or save an attached file (#4088). // The warnings go through `say` like any other console line, so they count against the // line and byte caps instead of growing the console past them. let mut notes = Vec::new(); for r in &o.requests { match r { + Request::Print => notes + .push("Print requested by an automatic field script was ignored; use a button or the JavaScript console to print".to_string()), Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), Request::OpenAttachment { name, .. } => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c18117ba42..15e868bd61 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6753,6 +6753,90 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { } } +#[test] +fn automatic_field_scripts_cannot_print() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "qty").unwrap(); + for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { + let mut runner = js::JsRunner::new(&cos, "order.pdf", false); + let result = runner.run( + event, + "console.println('before'); this.print(); app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment'); \ + this.pageNum = 0; this.print(); app.alert('ok'); event.value = '4';", + target, + "2", + &fields, + ); + assert!(result.rc, "{event:?}: {result:?}"); + assert_eq!(result.value, "4"); + assert_eq!(runner.output.requests, [js::Request::GoToPage(0)], "{event:?}: {:?}", runner.output); + assert_eq!(runner.output.alerts, [js::Message::plain("ok")]); + assert!(runner.output.console.contains(&"before".to_string())); + assert!(runner.output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{:?}", runner.output); + assert_eq!(runner.output.console.iter().filter(|line| line.contains("only a button may")).count(), 2, "{:?}", runner.output); + assert!(runner.output.errors.is_empty()); + + runner.output = js::JsOutput::default(); + runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields); + assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output); + assert!(runner.output.errors.iter().any(|e| e.contains("broken script"))); + } +} + +#[test] +fn automatic_print_suppression_preserves_document_errors() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "failure", Some("throw new Error('document failure');")).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "qty").unwrap(); + let mut runner = js::JsRunner::new(&cos, "order.pdf", false); + for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] { + runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields); + } + let out = runner.output; + assert!(out.requests.is_empty(), "{out:?}"); + assert_eq!(out.console.iter().filter(|line| line.contains("Print") && line.contains("ignored")).count(), 4, "{out:?}"); + assert_eq!(out.doc_errors.len(), 1, "the document script's error is reported once: {out:?}"); + assert!(out.doc_errors[0].contains("document failure"), "{out:?}"); + assert_eq!(out.errors.iter().filter(|e| e.contains("document failure")).count(), 1, "{out:?}"); + assert_eq!(out.errors.iter().filter(|e| e.contains("field failure")).count(), 4, "each field script's error is retained: {out:?}"); +} + +#[test] +fn form_calculations_cannot_print_but_buttons_and_console_can() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "total".into(), + event: "calculate".into(), + script: Some("this.print(); event.value = times(getField('price').value, getField('qty').value);".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "{out:?}"); + assert!(out.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{out:?}"); + assert!(out.errors.is_empty(), "{out:?}"); + assert!(page_texts(&s, id)[0].contains("USD 10.00")); + + for (target, qty, formatted) in [(Some("go"), 6, "USD 15.00"), (None, 8, "USD 20.00")] { + let out = s.run_javascript(id, &format!("getField('qty').value = {qty}; this.print();"), target).unwrap(); + assert_eq!(out.requests, [js::Request::Print]); + let automatic = s.take_js_output(id); + assert!(automatic.requests.is_empty(), "{automatic:?}"); + assert!(automatic.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{automatic:?}"); + assert!(automatic.errors.is_empty(), "{automatic:?}"); + assert!(page_texts(&s, id)[0].contains(formatted)); + } +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/docs/roadmap.md b/docs/roadmap.md index fa1b5e5c97..b2674a9776 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,9 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged) +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged) +> +> > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged) > **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) > @@ -48,6 +52,8 @@ > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6, #4088: automatic AcroForm field scripts cannot request printing; explicit button and console printing are retained; estimates unchanged) +> > > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: CJK fallback in Combine thumbnails and figure previews; FangSong and subset-prefixed Founder family matching; estimates unchanged) > @@ -250,7 +256,7 @@ total matches the parity estimate. | 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. | -| 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: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). | +| 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: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly).
Done since (#4088): automatic AcroForm field scripts ignore Print with a console diagnostic; explicit button and console printing remain available. Integrated main f137a289 (0.16.1) on 2026-10-31; ShowPanel/SaveAs filtering, all three engine regressions, the headless regression, field effects and document-error provenance are retained. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. | | 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). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -303,6 +309,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. + +**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged. **2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients. Synthetic rendering and save/reopen regressions cover the change. Estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8902c1787b..c198e6f4e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5411,8 +5411,8 @@ milestone = "M6" status = "shipped" commands = ["app.preferences"] tools = ["js_enabled"] -evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/it/document_links.rs::a_script_cannot_open_other_kinds_of_address"] -notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)." +evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/it/document_links.rs::a_script_cannot_open_other_kinds_of_address", "crates/engine/src/tests.rs::automatic_field_scripts_cannot_print", "crates/engine/src/tests.rs::automatic_print_suppression_preserves_document_errors", "crates/engine/src/tests.rs::form_calculations_cannot_print_but_buttons_and_console_can", "crates/automation/src/tests.rs::form_fill_cannot_print_but_js_run_can"] +notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91). Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot request printing; an ignored Print request is reported in the JavaScript console. Explicit button and console printing remain available (#4088). Integration with main f137a289 (0.16.1), validated on 2026-10-31 on Windows x64 / Rust 1.95: 761 engine/automation tests passed, 0 failed, 4 existing ignored; formatting, scoped all-target Clippy, layers/assets/parity, scoped engine/automation WebAssembly and a fresh CLI build passed. The 13-step synthetic CLI exercise preserved Qty=4, Total=8 and explicit button/console Print requests through save/render; the rendered values were inspected. Existing main warnings remain (100 headless parity notices, 5 renderer WebAssembly warnings, 3 optional egui patch notices). Native printer transport remains untested." [[feature]] id = "form.folder-level-js" From afca38ad714879f54ace023429ab1e4739f95fc2 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:05:43 +0100 Subject: [PATCH 2/7] M10: retain printed artwork with indirect annotation metadata (lands #4668) (#6855) * M10: retain printed artwork with indirect annotation rectangles * M10: resolve indirect annotation appearance states when printing * M10: follow bounded annotation reference chains when printing --------- Co-authored-by: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> --- ROADMAP.md | 12 ++ crates/print/README.md | 7 + crates/print/src/imposition.rs | 36 +++- crates/print/src/tests/appearances.rs | 293 ++++++++++++++++++++++++++ docs/roadmap.md | 10 + parity/acrobat-features.toml | 4 +- 6 files changed, 351 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 80b3687175..03f730704e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,9 @@ > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged) +> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -318,6 +321,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. - **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. - **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates are indirect, requiring exactly four finite resolved values. +- **2026-10-26 (M10):** Vector printing follows up to 64 indirect references for annotation rectangles, coordinates and appearance-state names, rejecting cycles and longer chains without changing source objects. +- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries. + Synthetic CLI and source regressions cover the behavior. + Estimates unchanged. + - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed. @@ -870,6 +879,9 @@ Estimates unchanged. | 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | +| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/crates/print/README.md b/crates/print/README.md index 9b11216f3d..d4478fcbf5 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -132,3 +132,10 @@ a matching page. On Windows craft-print sends the sheets as vectors with a Print for the sheet's exact paper and no scaling (#599's approach), validated by the driver: a job is refused, not shrunk, when the driver lacks the paper or would change it. Verify the driver's paper settings and physical calibration before relying on drawing scale. + +Vector printing resolves direct and indirect annotation rectangle coordinates before placing existing appearances. +Rectangles must contain exactly four finite numbers; malformed rectangles are skipped. +For an appearance-state dictionary, direct or indirect `AS` names select the existing artwork. +Rectangle arrays, individual coordinates and `AS` names follow up to 64 indirect references with cycle detection. +Missing, invalid, unresolved, cyclic, overly deep, or unknown state names leave that annotation unprinted; a single normal appearance stream does not require `AS`. +The print content mode still controls whether a field or comment prints, and printing preserves the source objects. diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 94add4713b..b59e620328 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -111,15 +111,30 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { } } -/// The normal appearance stream to draw for an annotation (honouring `/AS`). +/// Read an annotation value through at most 64 indirect references. +/// Standalone indirect objects can themselves hold references (ISO 32000-2 §7.3.10). +/// Cycles and longer chains are unusable, like other invalid annotation values. +fn annotation_value(doc: &Document, value: &Object) -> Option> { + let mut value = std::sync::Arc::new(value.clone()); + let mut seen = std::collections::HashSet::new(); + while let Object::Ref(reference) = &*value { + if seen.len() == 64 || !seen.insert(*reference) { + return None; + } + value = doc.get(*reference); + } + Some(value) +} + +/// The normal appearance stream to draw, resolving the annotation's `/AS` state name. pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { let ap = doc.resolve(d.get(b"AP")?); let normal = ap.as_dict()?.get(b"N")?.clone(); match &*doc.resolve(&normal) { Object::Stream(_) => normal.as_ref(), Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() + let state = annotation_value(doc, d.get(b"AS")?)?; + states.get(state.as_name()?)?.as_ref() } _ => None, } @@ -182,12 +197,15 @@ pub(crate) fn page_form( if !prints(doc, &d, content) { continue; } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + // A rectangle has four numbers, and each can be an indirect object (ISO 32000-2 §7.3.10). + let rect = d.get(b"Rect").and_then(|r| { + let rect = annotation_value(doc, r)?; + let [x0, y0, x1, y1] = rect.as_array()?.as_slice() else { return None }; + let number = |v: &Object| annotation_value(doc, v)?.as_f64().filter(|v| v.is_finite()); + Some([number(x0)?, number(y0)?, number(x1)?, number(y1)?]) + }); + let Some([x0, y0, x1, y1]) = rect else { continue }; + let rect = [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]; let Some(ap) = appearance(doc, &d) else { continue }; let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index c10948de75..bcb0dd60e5 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -79,6 +79,14 @@ fn saved(doc: &Document) -> Vec { write_full(doc, &SaveOptions::default()).unwrap() } +/// Wrap a synthetic value in exactly `depth` standalone indirect objects. +fn reference_chain(doc: &mut Document, mut value: Object, depth: usize) -> Object { + for _ in 0..depth { + value = Object::Ref(doc.add(value)); + } + value +} + fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { let resources = doc.resolve(owner.get(b"Resources").unwrap()); let objects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); @@ -185,6 +193,291 @@ fn annotation_appearances_do_not_replace_page_xobjects() { } } +/// Existing field/comment artwork must survive vector printing when any Rect coordinate is indirect. +#[test] +fn indirect_annotation_rectangles_keep_their_printed_appearances() { + for (subtype, content) in [("Widget", Content::Document), ("Square", Content::DocumentAndMarkups)] { + for outer_indirect in [false, true] { + for reversed in [false, true] { + for mask in [0usize, 1, 0b1010, 0b1111] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let values = if reversed { [50.0, 40.25, 10.0, 20.25] } else { [10.0, 20.25, 50.0, 40.25] }; + let coordinates = values.into_iter().enumerate().map(|(i, value)| { + let value = if i % 2 == 0 { Object::Int(value as i64) } else { Object::Real(value) }; + if mask & (1 << i) != 0 { Object::Ref(doc.add(value)) } else { value } + }); + let rect = Object::Array(coordinates.collect()); + let rect = if outer_indirect { Object::Ref(doc.add(rect)) } else { rect }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), rect); + }) + .unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), [0, 255, 0, 255], "source appearance"); + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + [0, 255, 0, 255], + "{subtype}, outer_indirect={outer_indirect}, reversed={reversed}, mask={mask:04b}" + ); + assert_eq!(printed.rgba, source.rgba, "reopened printed pixels match the source"); + assert_eq!(saved(&doc), before, "printing leaves the source objects unchanged"); + } + } + } + } +} + +/// State dictionaries select the same artwork for direct and indirect AS names, for both +/// widgets and comments, without changing the document or the selected print content mode. +#[test] +fn indirect_annotation_states_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for indirect_state in [false, true] { + for indirect_states in [false, true] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let states = Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])); + let states = if indirect_states { Object::Ref(doc.add(states)) } else { states }; + let state = Object::name(state_name); + let state = if indirect_state { Object::Ref(doc.add(state)) } else { state }; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Widget" { + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"V".to_vec(), Object::name(state_name)); + } else { + d.remove(b"FT"); + } + d.set(b"AS".to_vec(), state); + d.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), states)]))); + }) + .unwrap(); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let before = saved(&doc); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), color, "the source selects its stated appearance"); + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, indirect_state={indirect_state}, indirect_states={indirect_states}, {content:?}" + ); + assert_eq!(pixel(&printed, 4, 96), if content == Content::FormFieldsOnly { [255; 4] } else { [0, 0, 255, 255] }); + if shown && content != Content::FormFieldsOnly { + assert_eq!(printed.rgba, source.rgba, "the saved/reopened print keeps all page and annotation pixels"); + } + } + assert_eq!(saved(&doc), before, "printing does not rewrite the source annotation or appearance state"); + } + } + } + } +} + +/// Standalone indirect objects can contain further references, including rectangle arrays, +/// individual coordinates and AS names. Printing must match the equivalent direct values. +#[test] +fn chained_annotation_values_keep_their_printed_appearances() { + for subtype in ["Widget", "Square"] { + for (state_name, color) in [("Yes", [0, 255, 0, 255]), ("Off", [255, 0, 0, 255])] { + for (rect_depth, coordinate_depth, state_depth) in [(2, 0, 0), (0, 2, 0), (0, 0, 2), (2, 2, 2), (64, 64, 64)] { + let (mut doc, on, annot) = widget_fixture(Some(Object::name("Form")), None); + let off = doc.add(Object::Stream(Stream::flate(doc.get(on).as_dict().unwrap().clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f"))); + let values = [Object::Int(50), Object::Real(40.25), Object::Int(10), Object::Real(20.25)]; + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), Object::Array(values.to_vec())); + d.set(b"AS".to_vec(), Object::name(state_name)); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([( + b"N".to_vec(), + Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(on)), (b"Off".to_vec(), Object::Ref(off))])), + )])), + ); + }) + .unwrap(); + let modes = [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly]; + let controls: Vec<_> = modes.iter().map(|&content| render(impose(&doc, &print_settings(content)).unwrap())).collect(); + // Mix direct, one-hop and chained integer/real coordinates in the same rectangle. + let coordinates = values + .into_iter() + .enumerate() + .map(|(i, value)| { + reference_chain( + &mut doc, + value, + if coordinate_depth == 0 || i == 0 { + 0 + } else if i == 2 { + 1 + } else { + coordinate_depth + }, + ) + }) + .collect(); + let rect = reference_chain(&mut doc, Object::Array(coordinates), rect_depth); + let state = reference_chain(&mut doc, Object::name(state_name), state_depth); + doc.update_dict(annot, |d| { + d.set(b"Rect".to_vec(), rect); + d.set(b"AS".to_vec(), state); + }) + .unwrap(); + // ISO 32000-2 permits reference-to-reference objects outside object streams. + let options = SaveOptions { object_streams: false, ..SaveOptions::default() }; + let doc = Document::open(Arc::new(write_full(&doc, &options).unwrap())).unwrap(); + let before = write_full(&doc, &options).unwrap(); + let state = doc.read_state(); + let graph: Vec<_> = doc.scan_objects().collect(); + for (&content, control) in modes.iter().zip(controls) { + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let shown = subtype == "Widget" || content == Content::DocumentAndMarkups; + assert_eq!( + pixel(&printed, 30, 70), + if shown { color } else { [255; 4] }, + "{subtype}, {state_name}, depths={rect_depth}/{coordinate_depth}/{state_depth}, {content:?}" + ); + assert_eq!(printed.rgba, control.rgba, "chained values preserve the complete direct-control output"); + } + assert!(state.matches(&doc), "printing preserves the source read state"); + assert_eq!(doc.scan_objects().collect::>(), graph, "printing preserves the source object graph"); + assert_eq!(write_full(&doc, &options).unwrap(), before, "printing preserves standalone reference chains"); + } + } + } +} + +/// Cycles, missing targets and reference chains beyond the print reader's limit skip only +/// the unusable annotation, retaining page content and leaving source values unchanged. +#[test] +fn invalid_annotation_reference_chains_are_skipped_when_printing() { + for property in ["rectangle", "coordinate", "state"] { + for kind in ["cycle", "unresolved", "too-deep"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let value = match kind { + "cycle" => { + let first = doc.add(Object::Null); + let second = doc.add(Object::Ref(first)); + doc.set(first, Object::Ref(second)); + Object::Ref(first) + } + "unresolved" => reference_chain(&mut doc, Object::Ref(ObjRef::new(999_999, 0)), 2), + "too-deep" => { + let value = match property { + "rectangle" => numbers(&[10.0, 20.0, 50.0, 40.0]), + "coordinate" => Object::Int(40), + "state" => Object::name("Yes"), + _ => panic!("unknown property {property}"), + }; + reference_chain(&mut doc, value, 65) + } + _ => panic!("unknown kind {kind}"), + }; + doc.update_dict(annot, |d| match property { + "rectangle" => d.set(b"Rect".to_vec(), value), + "coordinate" => d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), value])), + "state" => { + d.set(b"AS".to_vec(), value); + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + } + _ => panic!("unknown property {property}"), + }) + .unwrap(); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "invalid {property} chain ({kind}) does not select artwork"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page content survives invalid {property} chain ({kind})"); + assert_eq!(saved(&doc), before, "invalid {property} chain ({kind}) is not rewritten"); + } + } +} + +/// Malformed, missing and cyclic AS values do not select arbitrary appearance states, while +/// a single normal appearance stream continues to work without a usable state name. +#[test] +fn invalid_annotation_states_do_not_select_an_appearance() { + for kind in ["absent", "null", "integer", "string", "missing-name", "unresolved", "cyclic"] { + let (mut doc, ap, annot) = widget_fixture(Some(Object::name("Form")), None); + let state = match kind { + "absent" => None, + "null" => Some(Object::Null), + "integer" => Some(Object::Int(1)), + "string" => Some(Object::String(pdfcraft_cos::PdfString::literal(b"Yes".to_vec()))), + "missing-name" => Some(Object::name("Missing")), + "unresolved" => Some(Object::Ref(ObjRef::new(999_999, 0))), + "cyclic" => { + let r = doc.add(Object::Null); + doc.set(r, Object::Ref(r)); + Some(Object::Ref(r)) + } + _ => panic!("unknown fixture kind {kind}"), + }; + doc.update_dict(annot, |d| { + if let Some(state) = state { + d.set(b"AS".to_vec(), state); + } + }) + .unwrap(); + assert_eq!(appearance(&doc, doc.get(annot).as_dict().unwrap()), Some(ap), "a normal stream does not need AS: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "the normal stream still prints: {kind}"); + doc.update_dict(annot, |d| { + d.set( + b"AP".to_vec(), + Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Dict(Dict::from_iter([(b"Yes".to_vec(), Object::Ref(ap))])))])), + ); + }) + .unwrap(); + let before = saved(&doc); + assert!(appearance(&doc, doc.get(annot).as_dict().unwrap()).is_none(), "invalid state must not choose Yes: {kind}"); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255; 4], "the invalid state is skipped: {kind}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "page artwork is retained: {kind}"); + assert_eq!(saved(&doc), before, "invalid source data is not rewritten: {kind}"); + } +} + +/// Malformed Rect values must not become printable rectangles by dropping invalid entries. +#[test] +fn invalid_annotation_rectangles_are_skipped_when_printing() { + for invalid in [ + Object::Null, + numbers(&[10.0, 20.0, 50.0]), + numbers(&[10.0, 20.0, 50.0, 40.0, 60.0]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), 40.into(), Object::Null]), + Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(ObjRef::new(999_999, 0))]), + numbers(&[f64::NAN, 20.0, 50.0, 40.0]), + numbers(&[10.0, f64::INFINITY, 50.0, 40.0]), + numbers(&[10.0, 20.0, f64::NEG_INFINITY, 40.0]), + ] { + for outer_indirect in [false, true] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let rect = if outer_indirect { Object::Ref(doc.add(invalid.clone())) } else { invalid.clone() }; + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), rect)).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "invalid={invalid:?}, outer_indirect={outer_indirect}"); + assert_eq!(pixel(&printed, 4, 96), [0, 0, 255, 255], "ordinary page content still prints"); + } + } + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + let cycle = doc.add(Object::Null); + doc.set(cycle, Object::Ref(cycle)); + doc.update_dict(annot, |d| d.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 50.into(), Object::Ref(cycle)]))).unwrap(); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "cyclic coordinate references are skipped"); +} + #[test] fn missing_subtype_appearance_survives_vector_print() { let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); diff --git a/docs/roadmap.md b/docs/roadmap.md index b2674a9776..11c7fcf655 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,6 +38,9 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged) +> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the optimizer stores Group 3 fax images as Group 4, and packs black-and-white RGB and indexed images that use only palette entries 0 and 1 to one bit; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white images above 450 ppi are averaged down to 300 ppi by the optimizer; estimates unchanged) @@ -262,6 +265,10 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries. Rectangle arrays, coordinates and state names follow up to 64 indirect references with cycle detection. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | @@ -361,6 +368,9 @@ Estimates unchanged. | 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. | +| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. | +| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c198e6f4e1..ce9188932e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8345,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients"] -notes = "Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path." +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"] +notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path." [[feature]] id = "print.print-with-comment-summary" From 3bcaacc272027dc7a7c6c4d7eb6beb7454d4d5d6 Mon Sep 17 00:00:00 2001 From: lordwotton29 <57874567+lordwotton29@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:19:34 +0100 Subject: [PATCH 3/7] fix(print): keep the localized reset label off the properties panel title (#5550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(print): keep the localized reset label off the properties panel title The header of Print ▸ Properties… laid the title out first and gave what was left of the row to Done and Reset. When the translated "Reset to printer defaults" needs more room than that, egui keeps placing the two buttons from the right edge of the child leftwards and paints them back over the title: in Italian the label is 51 characters against 25 in English, and both strings end up unreadable (#5545). Laid out with egui::Sides::shrink_left().truncate instead: the buttons take their room first and the title takes what is left of the row, cut with an ellipsis. Nothing moves in English, where the title still fits. * test(print): pin the header against every catalog and the title's tooltip The geometry tests behind #5545 tried English, Italian and German at four widths. They now walk every catalogued language at the row the settings column really gives the header (470 px less the frame's 10 px margins), so the longest translation of "Reset to printer defaults" is covered by the run instead of by a guess at which locale is the worst one. Two more tests: hovering the elided title opens a tooltip with the full string, while a title that fits whole opens none; and a row too narrow to give the title even an ellipsis leaves it out instead of painting it back over the buttons. That last case is why driver_options_header returns Option. Bulgarian (370 px of button), Spanish (365) and Ukrainian (361) fill the row with the two buttons alone at 450 px: the ellipsis is 12.4 px wide and the clearance was 0.1 px, so a pixel less and the label was painted over the buttons again. --------- Co-authored-by: Jarvis (Hermes) Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/ui-egui/src/workflows/print_ui/body.rs | 161 ++++++++++++++++-- 1 file changed, 150 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 7058233318..43e5323bd3 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -4,23 +4,47 @@ use super::{ Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, }; +/// The narrowest the title is still worth cutting: an ellipsis and a couple of pixels at the title's +/// font. Given less room than this, [`egui::Sides::truncate`] cannot cut any further and the label +/// is painted back over the buttons, which is the defect of #5545; the title is left out instead. +const TITLE_MIN_WIDTH: f32 = 16.0; + +/// The panel's own header: "Printer properties" on the left, Done and Reset on the right. +/// +/// The buttons take their room first, from the right (`shrink_left`), and the title gets what is +/// left of the row: [`egui::Sides::truncate`] cuts it with an ellipsis instead of letting a label wider than the row +/// spill over it. Laid out the other way round, the label is placed first at its natural width and +/// the buttons are painted back over it, which is what a translation of "Reset to printer +/// defaults" long enough to fill the row did in Italian (#5545). +/// +/// The title comes back as `None` when the row is too narrow for even an ellipsis, which a catalog +/// whose two buttons nearly fill the row reaches (Bulgarian, Ukrainian, Spanish at about 450 px). +fn driver_options_header(ui: &mut egui::Ui, show: &mut bool, can_reset: bool) -> (Option, egui::Response) { + egui::Sides::new().shrink_left().truncate().show( + ui, + |ui| { + let room = ui.available_width(); + (room >= TITLE_MIN_WIDTH).then(|| ui.add(egui::Label::new(egui::RichText::new(tl!("Printer properties")).font(theme::semibold(13.0))))) + }, + |ui| { + if ui.button(tl!("Done")).clicked() { + *show = false; + } + ui.add_enabled(can_reset, egui::Button::new(tl!("Reset to printer defaults"))) + }, + ) +} + /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. /// Only choices away from the printer's defaults are kept, and sent with the job. fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { let PrintDraft { driver_options: Some((_, options)), driver_choices: choices, show_driver_options: show, .. } = d else { return }; egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Printer properties")).font(theme::semibold(13.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Done")).clicked() { - *show = false; - } - if ui.add_enabled(!choices.is_empty(), egui::Button::new(tl!("Reset to printer defaults"))).clicked() { - choices.clear(); - } - }); - }); + let (_, reset) = driver_options_header(ui, show, !choices.is_empty()); + if reset.clicked() { + choices.clear(); + } if options.is_empty() { ui.label(egui::RichText::new(tl!("This printer offers no settings of its own.")).small().color(t.text_muted)); return; @@ -670,8 +694,123 @@ mod tests { use egui_kittest::Harness; use egui_kittest::kittest::Queryable; + use super::*; + use crate::i18n::Lang; use crate::{Dialog, PdfCraftApp}; + /// The row the settings column really gives the header: `body` sets it to 470 px, less the + /// frame's 10 px margins. + const PANEL_ROW: f32 = 450.0; + + /// One frame of the header at `width`, drawn in the app's own fonts; with `hover`, the pointer + /// is parked on the title and the frames after the first are run so that a tooltip can come up. + /// Returns the title's rectangle (none when the title was left out), the reset button's, and + /// whether a tooltip layer is open. + fn header_frame(lang: &str, width: f32, hover: bool) -> (Option, egui::Rect, bool) { + crate::i18n::set_current(Lang::from_code(lang).expect("a catalogued language")); + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + // Without this the tooltip would need half a second of frames to show. + ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); + let row = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0)); + let pointer = std::cell::Cell::new(None); + let seen = std::cell::Cell::new((None, egui::Rect::NOTHING)); + for _ in 0..3 { + let mut input = egui::RawInput { screen_rect: Some(row), ..Default::default() }; + if let Some(at) = hover.then(|| pointer.get()).flatten() { + input.events.push(egui::Event::PointerMoved(at)); + } + let mut output = ctx.run_ui(input, |ui| { + let mut show = true; + let (title, reset) = driver_options_header(ui, &mut show, true); + let title = title.map(|title| title.rect); + pointer.set(title.map(|title| title.center())); + seen.set((title, reset.rect)); + }); + // Nothing draws the frame, but epaint's deltas must be taken anyway. + output.textures_delta.clear(); + } + let tooltip = ctx.memory(|memory| memory.areas().visible_layer_ids().iter().any(|layer| layer.order == egui::Order::Tooltip)); + crate::i18n::set_current(Lang::EN); + let (title, reset) = seen.get(); + (title, reset, tooltip) + } + + fn header_at(lang: &str, width: f32) -> (Option, egui::Rect) { + let (title, reset, _) = header_frame(lang, width, false); + (title, reset) + } + + /// #5545: in a long locale the reset label is wider than the row, and it used to be painted + /// over the title. Every catalogued language is checked at the row the panel really gives the + /// header, so the longest translation of "Reset to printer defaults" is covered by the run + /// rather than by a guess at which locale is the worst one. + #[test] + fn the_reset_button_never_covers_the_title_in_any_language() { + if !crate::i18n::needs_catalogs() { + return; + } + for lang in Lang::all() { + let (title, reset) = header_at(lang.code(), PANEL_ROW); + assert!( + title.is_none_or(|title| !title.intersects(reset)), + "{} at {PANEL_ROW}px: the title {title:?} is drawn on top of the reset button {reset:?}", + lang.code() + ); + assert!(reset.right() <= PANEL_ROW, "{}: the reset button {reset:?} runs out of the row", lang.code()); + } + } + + /// A row narrower than the buttons plus one ellipsis cannot be cut any further: the title is + /// left out instead of being painted back over the buttons. Bulgarian, Ukrainian and Spanish + /// are the catalogs that get there first, at the row's own width. + #[test] + fn a_row_without_room_for_a_title_leaves_it_out() { + if !crate::i18n::needs_catalogs() { + return; + } + for width in [449.0, 440.0, 400.0, 300.0] { + for lang in ["bg", "uk", "es", "it", "en"] { + let (title, reset) = header_at(lang, width); + assert!( + title.is_none_or(|title| !title.intersects(reset)), + "{lang} at {width}px: the title {title:?} is drawn on top of the reset button {reset:?}" + ); + } + } + let (title, reset) = header_at("bg", 440.0); + assert!(title.is_none(), "the title is dropped, not squeezed to a sliver: {:?} next to {reset:?}", title); + } + + /// The title is what gives way: it truncates in a narrow row while the buttons keep their + /// size, and a wide row keeps it whole instead of cutting it for nothing. + #[test] + fn the_title_truncates_and_leaves_the_buttons_whole() { + let (wide_title, wide_reset) = header_at("en", 620.0); + let (narrow_title, narrow_reset) = header_at("en", 300.0); + let (wide_title, narrow_title) = (wide_title.expect("the wide row keeps the title"), narrow_title.expect("the narrow row keeps a title")); + assert!(narrow_title.width() < wide_title.width(), "the title shrinks in a narrow row"); + assert_eq!(narrow_reset.width(), wide_reset.width(), "the buttons keep their size"); + assert!(narrow_reset.right() <= 301.0, "the reset button stays inside the row"); + } + + /// The cut title stays reachable: hovering it shows the full string in a tooltip (egui's + /// `Label` does that by itself when it elides), and a title that fits whole shows none. + #[test] + fn the_truncated_title_is_reachable_through_a_tooltip() { + if !crate::i18n::needs_catalogs() { + return; + } + let (whole, _, _) = header_frame("it", 620.0, false); + let whole = whole.expect("a wide row keeps the title"); + let (cut, _, tooltip) = header_frame("it", PANEL_ROW, true); + let cut = cut.expect("the Italian title is elided, not dropped"); + assert!(cut.width() < whole.width(), "the Italian title is cut at {PANEL_ROW}px: {} of {}", cut.width(), whole.width()); + assert!(tooltip, "hovering the elided title opens a tooltip with the full string"); + let (_, _, tooltip) = header_frame("en", 620.0, true); + assert!(!tooltip, "a title that fits whole needs no tooltip"); + } + /// #4252, #5317: on a short screen the settings+preview area used to grow past /// both the top and bottom of the screen (centered modals overflow both edges equally), /// taking the Cancel button with it. It must now stay reachable, scrolling instead. From 0e4b9171c0fa3a04d36aaa83b45c60aa7f79b8d6 Mon Sep 17 00:00:00 2001 From: Vansh Patel Date: Tue, 10 Nov 2026 20:54:33 +0530 Subject: [PATCH 4/7] fix(ui-egui): keep hotbar picker stationary while resizing (#6513) * fix(ui-egui): keep hotbar picker stationary while resizing * fix(ui-egui): keep hotbar picker pinned during keyboard resize Pin the Hotbar tools picker while the Toolbar size slider is adjusted with the arrow keys, until the slider loses focus. Widen the regression test to both drag directions and every layout, add a keyboard test, and document the new helpers. * fix(ui-egui): closing the hotbar picker ends a size adjustment close_picker() also clears picker_drag_pos and keyboard_sizing. Main's close_hidden_picker() closes the picker on passes that don't draw the hotbar, where show() can't clear them, and the next opening would start at the held position. Adds closing_the_picker_ends_a_size_adjustment, a doc comment for picker_drag_pos, and says "picker" where the comment meant it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: FredApps Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/hotbar.rs | 185 ++++++++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 4d4539a465..e0730de201 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -23,6 +23,12 @@ pub(crate) struct Hotbar { search: String, #[serde(skip)] picker_open: bool, + /// Where the Hotbar tools window stays while the Toolbar size slider is being adjusted. + #[serde(skip)] + picker_drag_pos: Option, + /// The Toolbar size slider is being adjusted with the keyboard (until it loses focus). + #[serde(skip)] + keyboard_sizing: bool, /// The pass the Hotbar tools window was last drawn on. #[serde(skip)] picker_shown: Option, @@ -51,6 +57,10 @@ impl Hotbar { pub(crate) fn close_picker(&mut self) { self.picker_open = false; self.picker_shown = None; + // Closed on a pass that doesn't draw the hotbar, `show` can't end a size adjustment: the + // next opening would start at the held position. + self.picker_drag_pos = None; + self.keyboard_sizing = false; } } @@ -67,6 +77,8 @@ impl Default for Hotbar { placed: false, search: String::new(), picker_open: false, + picker_drag_pos: None, + keyboard_sizing: false, picker_shown: None, } } @@ -573,6 +585,31 @@ fn segmented_dock_picker(ui: &mut egui::Ui, bar: &mut Hotbar, t: &Tokens) { } } +/// The arrow keys the Toolbar size slider answers to while it has focus. +const SIZE_SLIDER_KEYS: [egui::Key; 4] = [egui::Key::ArrowLeft, egui::Key::ArrowRight, egui::Key::ArrowUp, egui::Key::ArrowDown]; + +/// Whether an arrow key was pressed this frame. Read it before the slider is added, because the +/// slider consumes the keys it acts on. +fn size_slider_key_pressed(ui: &egui::Ui) -> bool { + ui.input(|input| SIZE_SLIDER_KEYS.iter().any(|key| input.key_pressed(*key))) +} + +/// Whether the Toolbar size slider is being adjusted, which keeps its picker from moving. +/// +/// A pointer adjustment lasts while the button is held. A keyboard one starts with the first arrow +/// key press that changes the value and lasts until the slider loses focus, so the picker does not +/// jump between presses. `keyboard` carries that state from frame to frame. Focusing the slider, or +/// releasing the pointer on it, does not count as a keyboard adjustment. +fn size_slider_adjusting(slider: &egui::Response, key_pressed: bool, keyboard: &mut bool) -> bool { + let pointer = slider.is_pointer_button_down_on(); + if pointer || !slider.has_focus() { + *keyboard = false; + } else if key_pressed && slider.changed() { + *keyboard = true; + } + pointer || *keyboard +} + /// Draw the unified floating or side-docked toolbar, combining quick actions, core tools, and custom shortcuts. pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -761,7 +798,11 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui } else { bar_rect.top().clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top())) }; - let picker_pos = egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y); + // Resizing the bar can move or flip its picker; hold the picker in place until the pointer is + // released, or, for the keyboard, until the slider loses focus. + let picker_pos = + bar.picker_drag_pos.unwrap_or_else(|| egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y)); + let mut sizing = false; let mut close_dialog = false; let picker_resp = egui::Window::new(tl!("Hotbar tools")) .id(picker_id) @@ -803,7 +844,9 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui ui.label(egui::RichText::new(tl!("Toolbar size")).size(12.5).color(t.text)); let slider_width = (ui.available_width() - 55.0).clamp(50.0, 140.0); ui.spacing_mut().slider_width = slider_width; - ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false)); + let key_pressed = size_slider_key_pressed(ui); + let slider = ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false)); + sizing = size_slider_adjusting(&slider, key_pressed, &mut bar.keyboard_sizing); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(format!("{:.0} px", bar.button_size)).size(12.0).color(t.text_muted)); }); @@ -896,6 +939,10 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui if close_dialog { picker_open = false; } + if !picker_open { + bar.keyboard_sizing = false; + } + bar.picker_drag_pos = (picker_open && sizing).then_some(picker_pos); ctx.data_mut(|data| { data.insert_temp(id, shown.response.rect.size()); data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size()); @@ -911,6 +958,140 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui #[cfg(test)] mod tests { use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + // Build a harness that draws the hotbar docked to `$side`, with its tools picker open. + macro_rules! hotbar_harness { + ($side:expr, $vertical:expr, $full_side:expr) => {{ + let mut app = PdfCraftApp::new(); + app.hotbar.place($side).unwrap(); + app.hotbar.vertical = $vertical; + app.hotbar.full_side = $full_side; + app.hotbar.position = [0.5, 0.5]; + app.hotbar.picker_open = true; + let mut fonts = false; + let mut harness = Harness::builder().with_size(vec2(1000.0, 900.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut PdfCraftApp| { + if !fonts { + ui.ctx().set_fonts(theme::font_definitions()); + fonts = true; + } + show(app, ui.max_rect(), None, ui); + }, + app, + ); + harness.run_steps(5); + harness + }}; + } + + // Drag the size slider from `$from` to `$to` and release, checking on every step that the picker + // (found through its slider) has not left `$pinned`. + macro_rules! drag_slider_pinned { + ($h:expr, $pinned:expr, $from:expr, $to:expr, $what:expr) => {{ + let (from, to): (egui::Pos2, egui::Pos2) = ($from, $to); + $h.hover_at(from); + $h.run_steps(1); + $h.drag_at(from); + $h.run_steps(1); + for step in 1..=8 { + $h.hover_at(from + (to - from) * (step as f32 / 8.0)); + $h.run_steps(2); + assert_eq!($h.get_by_role(egui::accesskit::Role::Slider).rect(), $pinned, "{}: picker moved during drag", $what); + } + $h.drop_at(to); + $h.run_steps(3); + }}; + } + + /// Dragging the Toolbar size slider with the pointer keeps the picker where it is, in both + /// directions and in every layout, and the picker takes its recalculated place after release. + #[test] + fn picker_stays_put_while_resizing_hotbar() { + for (side, vertical, full_side) in [ + ("top", false, false), + ("bottom", false, false), + ("float", false, false), + ("top", false, true), + ("bottom", false, true), + ("left", true, false), + ("right", true, false), + ("float", true, false), + ("left", true, true), + ("right", true, true), + ] { + let what = format!("{side}, vertical={vertical}, strip={full_side}"); + let mut h = hotbar_harness!(side, vertical, full_side); + + // Drag the slider up to the largest size. + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + drag_slider_pinned!(h, slider, slider.left_center() + vec2(slider.width() * 0.2, 0.0), slider.right_center() - vec2(2.0, 0.0), what); + assert!(h.state().hotbar.button_size > 60.0, "{what}: slider did not reach the upper end"); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: release must unpin the picker"); + let resized = h.get_by_role(egui::accesskit::Role::Slider).rect(); + assert_ne!(resized, slider, "{what}: picker should take its new place after release"); + if !vertical && !full_side { + assert!(resized.left() < slider.left(), "{what}: picker should move to the left after release"); + } + + // And back down to the smallest. + drag_slider_pinned!(h, resized, resized.right_center() - vec2(2.0, 0.0), resized.left_center() + vec2(2.0, 0.0), what); + assert!(h.state().hotbar.button_size < 28.0, "{what}: slider did not reach the lower end"); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: release must unpin the picker"); + let restored = h.get_by_role(egui::accesskit::Role::Slider).rect(); + assert_ne!(restored, resized, "{what}: picker should take its new place after the reverse drag"); + if !vertical && !full_side { + assert!(restored.left() > resized.left(), "{what}: picker should return to the right"); + } + } + } + + /// Adjusting the Toolbar size slider with the arrow keys also keeps the picker where it is, until + /// the slider loses focus. + #[test] + fn picker_stays_put_while_resizing_hotbar_with_keyboard() { + for (side, vertical, full_side) in [("top", false, false), ("float", false, false), ("bottom", false, true), ("left", true, false)] { + let what = format!("{side}, vertical={vertical}, strip={full_side}"); + let mut h = hotbar_harness!(side, vertical, full_side); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + h.get_by_role(egui::accesskit::Role::Slider).focus(); + h.run_steps(2); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: focusing the slider alone must not pin the picker"); + + // The slider moves about a pixel per key press, so this crosses its whole travel. + for _ in 0..150 { + h.key_press(egui::Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.get_by_role(egui::accesskit::Role::Slider).rect(), slider, "{what}: picker moved during keyboard resize"); + } + assert!(h.state().hotbar.button_size > 60.0, "{what}: keys did not reach the upper end"); + assert!(h.state().hotbar.picker_drag_pos.is_some(), "{what}: picker must stay pinned while the slider has focus"); + + // Moving focus away ends the adjustment. + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "{what}: losing focus must unpin the picker"); + assert_ne!(h.get_by_role(egui::accesskit::Role::Slider).rect(), slider, "{what}: picker should take its new place after focus leaves"); + } + } + + /// A picker closed while the hotbar isn't drawn (Read mode, full screen, Organize) ends a size + /// adjustment too, so it doesn't open again at the position it was held at. + #[test] + fn closing_the_picker_ends_a_size_adjustment() { + let mut h = hotbar_harness!("top", false, false); + h.get_by_role(egui::accesskit::Role::Slider).focus(); + h.run_steps(2); + for _ in 0..20 { + h.key_press(egui::Key::ArrowRight); + h.run_steps(1); + } + assert!(h.state().hotbar.picker_drag_pos.is_some() && h.state().hotbar.keyboard_sizing, "pinned while the keys resize the bar"); + // What `close_hidden_picker` does on a pass that hides the hotbar: `show` doesn't run. + h.state_mut().hotbar.close_picker(); + assert!(h.state().hotbar.picker_drag_pos.is_none(), "closing ends the held position"); + assert!(!h.state().hotbar.keyboard_sizing, "closing ends the keyboard adjustment"); + } #[test] fn hotbar_validation_and_placement() { From 6cded6a5a3ed53ecd9f3bb3edc1f98a6434a1f6e Mon Sep 17 00:00:00 2001 From: austin62jake Date: Tue, 10 Nov 2026 09:34:48 -0600 Subject: [PATCH 5/7] M10: a booklet's both sides follow a driver that keeps its own two-sided setting (#5772) (#5903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M10: a booklet's both sides follow a driver that keeps its own two-sided setting (#5772) Booklet ▸ Both sides always asked for two-sided printing on the short edge (#2707). A driver that doesn't take two-sided settings from other programs (no two-sided printing, manual two-sided only in its Properties, Microsoft Print to PDF) then made craft-print refuse the whole job. The reporter's Epson ET-2870 offers only OneSided, and its manual two-sided setting is what prints their normal two-sided jobs. print::booklet_job_duplex asks such a driver for nothing on both sides, so its own setting applies, as for ordinary printing (#2422) and as Acrobat enables two-sided for a booklet only "if available". Unknown support still asks for the short edge; one side on its own stays one-sided. The Print dialog takes the chosen printer's support from the printer list (PrintDraft::printer_two_sided, also behind its "its own setting applies" note), and doc_print reads it only for a booklet's both sides. The dialog's booklet test used the machine's printers, and on Windows the default, Microsoft Print to PDF, now gives the other answer: it sets its own printers and checks the note too. Co-Authored-By: Claude Opus 5.5 * M10: doc_print's lookup of a booklet printer's two-sided support is tested (#5772) The booklet test fed job_duplex the printer's answer directly, so a lookup that missed the default printer or matched the wrong one would have gone unnoticed. The search over the printer list is now its own function, two_sided_in, tested with a list of named, default and unknown printers. Co-Authored-By: Claude Opus 5.5 * M10: a booklet printed to "default" goes to the printer whose driver was asked (#5772) doc_print decides a booklet's both sides from the printer's two-sided support. For "default", the job named no printer, so the spooler picked whichever printer was the default when the job was submitted, after the document was rendered. The printer found in the list is now the job's printer, by name, and the lookup test covers a one-sided default. Co-Authored-By: Claude Opus 5.5 * #5903: the booklet test lists its printers through a stand-in source It set the dialog's printer list by hand while the real list was still loading. On a machine with no printers (CI's Linux runner) the list that arrived replaced them and reset the printer, so the dialog the test clicked through was not the one it set up. The printers now come from the dialog's own source, drivers unasked as #3554 lists them, with each driver's two-sided answer in the map. --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/automation/src/printing.rs | 63 ++++++++++++++++--- crates/print/src/lib.rs | 2 +- crates/print/src/settings.rs | 14 +++++ crates/print/src/tests.rs | 13 ++++ crates/ui-egui/src/workflows/print_ui.rs | 27 ++++++++ crates/ui-egui/src/workflows/print_ui/body.rs | 6 +- .../ui-egui/src/workflows/print_ui/draft.rs | 15 ++++- crates/ui-egui/tests/it/print.rs | 28 ++++++++- parity/acrobat-features.toml | 4 +- 9 files changed, 151 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index ba15639039..ca423b194b 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -23,15 +23,24 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { } /// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet -/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). -fn job_duplex(value: &str, layout: &Layout) -> Result> { +/// decides two-sided itself (#2707) as far as the printer's driver takes it, as the Print dialog +/// does (`print::booklet_job_duplex`). `two_sided` says whether it does; it is only asked for a +/// booklet's both sides, the one job it changes. +fn job_duplex(value: &str, layout: &Layout, two_sided: impl FnOnce() -> Option) -> Result> { let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; Ok(match layout { - Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + Layout::Booklet { subset: BookletSubset::BothSides, .. } => print::booklet_job_duplex(BookletSubset::BothSides, two_sided()), + Layout::Booklet { subset, .. } => print::booklet_job_duplex(*subset, None), _ => chosen, }) } +/// `printer` (a name, or "default" for the default printer) in `printers`; `None` when the list +/// has no such printer. +fn printer_in(printers: Vec, printer: &str) -> Option { + printers.into_iter().find(|p| if printer == "default" { p.default } else { p.name == printer }) +} + /// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) @@ -242,7 +251,15 @@ impl Automation { }, l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?), }; - let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; + let printer = a.opt_str("printer")?; + // The printer whose driver a booklet's both sides were decided for. The job goes to it by + // name, so "default" can't become another printer while the document is rendered. The + // queues are listed without asking every driver (#2192); only this one is asked. + let mut asked = None; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout, || { + asked = printer.and_then(|p| printer_in(spool::printer_queues(), p)); + spool::two_sided(&asked.as_ref()?.name) + })?; if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cols and rows need layout multiple")); } @@ -285,13 +302,14 @@ impl Automation { let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); - match (a.opt_str("path")?, a.opt_str("printer")?) { + match (a.opt_str("path")?, printer) { (Some(p), None) => { let path = self.resolve(p, true)?; write_atomic(&path, &bytes)?; out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { + let printer = asked.as_ref().map_or(printer, |p| p.name.as_str()); let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; let bytes = pdfcraft_engine::spooler::spooler_copy(bytes); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); @@ -320,16 +338,41 @@ mod tests { use super::*; /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + /// #5772: its both sides leave two-sided to a driver that doesn't take the setting, and only + /// they look the printer up. #[test] fn a_booklet_decides_two_sided_itself() { let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 }; + let unread = || -> Option { panic!("the printer's two-sided support is read for nothing") }; for asked in ["printer", "off", "long-edge", "short-edge"] { - assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); - assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); - assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(true)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || None).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides), || Some(false)).ok(), Some(None), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly), unread).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); } - assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); - assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides), || None).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit), unread).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + + /// #5772: a booklet's printer is found by name, or as the default for "default", and only + /// what the list says about it counts. "default" is found by its name, which the job is sent + /// to. + #[test] + fn a_booklets_printer_comes_from_the_list() { + let printer = |name: &str, default, two_sided| spool::Printer { name: name.into(), default, two_sided, description: String::new() }; + let list = [printer("Epson", false, Some(false)), printer("Laser", true, Some(true)), printer("Network", false, None)]; + let found = |list: &[spool::Printer], name| printer_in(list.to_vec(), name).map(|p| (p.name, p.two_sided)); + assert_eq!(found(&list, "Epson"), Some(("Epson".into(), Some(false)))); + assert_eq!(found(&list, "Laser"), Some(("Laser".into(), Some(true)))); + assert_eq!(found(&list, "default"), Some(("Laser".into(), Some(true))), "the default printer"); + assert_eq!(found(&list, "Network"), Some(("Network".into(), None)), "the list doesn't say"); + assert_eq!(found(&list, "Gone"), None, "no such printer"); + assert_eq!(found(&list, "epson"), None, "names are exact"); + let default_one_sided = [printer("Epson", true, Some(false)), printer("Laser", false, Some(true))]; + assert_eq!(found(&default_one_sided, "default"), Some(("Epson".into(), Some(false))), "the default printer"); + let without_default = [printer("Epson", false, Some(false))]; + assert_eq!(found(&without_default, "default"), None); } /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 84cca070da..885a5953b3 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -39,7 +39,7 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, driver_needs_grayscale, media_option, + booklet_job_duplex, cups_media, driver_needs_grayscale, media_option, }; #[cfg(test)] use sheets::text_direction; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index bdcb3e3540..619f775926 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -50,6 +50,20 @@ pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { } } +/// What a booklet's job asks the printer for, given whether its driver takes two-sided settings +/// from other programs (`two_sided`, as the printer list reports it): [`booklet_duplex`], only as +/// Acrobat enables two-sided printing "if available". A driver that doesn't (`Some(false)`) would +/// refuse a two-sided job and print nothing, so both sides ask for nothing and its own setting +/// applies, such as manual two-sided printing chosen in its Properties (#5772). A driver of +/// unknown support is asked for two-sided as before; one side on its own is always one-sided. +pub fn booklet_job_duplex(subset: BookletSubset, two_sided: Option) -> Option { + match booklet_duplex(subset) { + spool::Duplex::Off => Some(spool::Duplex::Off), + _ if two_sided == Some(false) => None, + two_sided_duplex => Some(two_sided_duplex), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Binding { #[default] diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 402b2aaa13..29d22d8eff 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1538,6 +1538,19 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } +/// #5772: both sides ask a driver that takes two-sided settings for the short edge, and one that +/// doesn't for nothing, so its own setting (manual two-sided, say) prints the booklet rather than +/// the job being refused. One side on its own asks every driver for one side. +#[test] +fn a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own() { + use BookletSubset::{BackOnly, BothSides, FrontOnly}; + for (two_sided, both) in [(Some(true), Some(spool::Duplex::ShortEdge)), (None, Some(spool::Duplex::ShortEdge)), (Some(false), None)] { + assert_eq!(booklet_job_duplex(BothSides, two_sided), both, "{two_sided:?}"); + assert_eq!(booklet_job_duplex(FrontOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + assert_eq!(booklet_job_duplex(BackOnly, two_sided), Some(spool::Duplex::Off), "{two_sided:?}"); + } +} + /// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` /// comment at the end of a piece without a line break continues through the first line of the /// next piece in both `impose` and `text_reads_up`. diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 89b51b34e2..c5b90ec12a 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -832,6 +832,33 @@ mod tests { assert!(cut.settings(4, &[]).is_err()); } + /// #5772: a booklet's both sides ask the printer for the short edge, unless its driver doesn't + /// take two-sided settings: it would refuse the job, so its own setting (manual two-sided, say) + /// prints it. One side on its own stays one-sided. + #[test] + fn a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own() { + // Listed as the dialog lists them (#3554), drivers unasked; the answers for the printers + // picked arrive in `two_sided`, and the network printer's never came. + let printer = |name: &str| spool::Printer { name: name.into(), default: false, two_sided: None, description: String::new() }; + let mut d = PrintDraft { + printers: vec![printer("Epson"), printer("Laser"), printer("Network")], + two_sided: [("Epson".to_string(), Some(false)), ("Laser".to_string(), Some(true))].into_iter().collect(), + handling: Handling::Booklet, + ..PrintDraft::default() + }; + for (name, both) in [("Epson", None), ("Laser", Some(spool::Duplex::ShortEdge)), ("Network", Some(spool::Duplex::ShortEdge))] { + d.printer = Some(name.into()); + d.booklet_subset = BookletSubset::BothSides; + assert_eq!(d.job("a.pdf").duplex, both, "{name}"); + d.booklet_subset = BookletSubset::FrontOnly; + assert_eq!(d.job("a.pdf").duplex, Some(spool::Duplex::Off), "{name}"); + } + // Save as PDF has no driver: as before. + d.printer = None; + d.booklet_subset = BookletSubset::BothSides; + assert_eq!(d.job("a.pdf").duplex, Some(spool::Duplex::ShortEdge)); + } + /// A job being spooled holds the progress card, counting its sheets as the spooler reports /// them; the card goes when the job ends and its answer arrives as a notice. A card another /// long job is showing is left alone. diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 43e5323bd3..d4c9b3444d 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -288,10 +288,8 @@ pub(crate) fn body( } }); // Windows says which drivers can't print two-sided as they're asked; they keep their - // own setting, even when Two-sided is Off. - if let Some(p) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) - && p.two_sided.or_else(|| d.two_sided.get(&p.name).copied().flatten()) == Some(false) - { + // own setting, even when Two-sided is Off, and for a booklet's both sides. + if d.printer_two_sided() == Some(false) { ui.label(egui::RichText::new(tl!("This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…")).small().color(t.text_muted)); } if d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER) { diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index e7ad00df79..cf65351141 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -312,6 +312,16 @@ impl PrintDraft { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } + /// Whether the chosen printer's driver takes two-sided settings from other programs: + /// `Some(false)` for one that keeps its own (no two-sided printing, or only manual two-sided in + /// its Properties); `None` when unknown, and for Save as PDF. The printer list says when it + /// knows; on Windows it lists the queues without asking their drivers (#3554), and the answer + /// for the printer picked arrives in [`PrintDraft::two_sided`]. + pub fn printer_two_sided(&self) -> Option { + let p = self.printers.iter().find(|p| self.printer.as_ref() == Some(&p.name))?; + p.two_sided.or_else(|| self.two_sided.get(&p.name).copied().flatten()) + } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { @@ -320,11 +330,12 @@ impl PrintDraft { copies: self.copies.max(1), collate: self.collate, // Cut and stack needs single-sided sheets, whatever the printer's default; a booklet - // decides two-sided itself (#2707), leaving the Two-sided choice as it was. + // decides two-sided itself (#2707), as far as the driver takes it (#5772), leaving the + // Two-sided choice as it was. duplex: if self.cut_and_stack() { Some(spool::Duplex::Off) } else if self.handling == Handling::Booklet { - Some(print::booklet_duplex(self.booklet_subset)) + print::booklet_job_duplex(self.booklet_subset, self.printer_two_sided()) } else { self.duplex }, diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 5e57179b13..89f9885477 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -1199,12 +1199,24 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() { /// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided /// while Booklet is chosen): the job flips on the short edge for both sides and prints /// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. +/// #5772: on a printer whose driver doesn't take two-sided settings, both sides leave it to the +/// driver's own setting, and the dialog says so. #[test] fn a_booklet_decides_two_sided_itself() { - use pdfcraft_engine::print::{BookletSubset, spool::Duplex}; + use pdfcraft_engine::print::{BookletSubset, spool::Duplex, spool::Printer}; let mut h = harness(); + // Printers of its own, listed as the dialog lists them (drivers unasked, #3554), with what each + // driver said about two-sided: the machine's would decide what a booklet asks for, and on a + // machine with none the list that loads would take these away. + h.state_mut().printer_source = Some(std::sync::Arc::new(|| { + let printer = |name: &str| Printer { name: name.into(), default: name == "Laser", two_sided: None, description: String::new() }; + vec![printer("Laser"), printer("Inkjet")] + })); + h.state_mut().two_sided_source = None; + h.state_mut().print_draft.two_sided = [("Laser".to_string(), Some(true)), ("Inkjet".to_string(), Some(false))].into_iter().collect(); assert!(h.state_mut().execute("print.dialog")); - h.run_steps(3); + settle(&mut h); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Laser")); h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); h.run_steps(1); assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); @@ -1222,6 +1234,18 @@ fn a_booklet_decides_two_sided_itself() { h.state_mut().print_draft.booklet_subset = subset; assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex"); } + let keeps_its_own = + "This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…"; + assert!(h.query_by_label(keeps_its_own).is_none()); + h.state_mut().print_draft.printer = Some("Inkjet".into()); + h.state_mut().print_draft.booklet_subset = BookletSubset::BothSides; + h.run_steps(2); + assert_eq!(h.state().print_draft.job("x").duplex, None, "the inkjet's own two-sided setting prints both sides"); + assert!(h.query_by_label(keeps_its_own).is_some(), "the dialog says so"); + h.state_mut().print_draft.booklet_subset = BookletSubset::FrontOnly; + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "one side stays one-sided"); + h.state_mut().print_draft.printer = Some("Laser".into()); + h.run_steps(1); h.get_by_label("Size").click(); h.run_steps(2); assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ce9188932e..eced73a64a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8309,8 +8309,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge", "crates/print/src/tests.rs::booklet_creep_moves_inner_sheets_toward_the_fold"] -notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707); creep compensation (points per sheet, default 0) moves each sheet's pages toward the fold by their sheet's place from the outside, clipped at the fold (#6777); the amount is entered, not worked out from the paper's thickness" +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge", "crates/print/src/tests.rs::booklet_creep_moves_inner_sheets_toward_the_fold", "crates/print/src/tests.rs::a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own", "crates/ui-egui/src/workflows/print_ui.rs::a_booklet_leaves_two_sided_to_a_driver_that_keeps_its_own"] +notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge (as Acrobat, \"if available\": a printer whose driver doesn't take two-sided settings, such as one with only manual two-sided in its Properties or Microsoft Print to PDF, is asked for nothing, so its own setting applies rather than the job being refused, #5772), front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707). Not yet: a guided manual two-sided pass (front, turn the stack, back) on such a printer; creep compensation (points per sheet, default 0) moves each sheet's pages toward the fold by their sheet's place from the outside, clipped at the fold (#6777); the amount is entered, not worked out from the paper's thickness" [[feature]] id = "print.poster" From 919db9bd5154ad26b937c3c7761f278635864790 Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:04:04 -0600 Subject: [PATCH 6/7] M10: preserve NoRotate appearance geometry in vector printing (#5036) * M10: preserve annotation placement precision (#4995) * M10: preserve NoRotate and implicit Text print orientation (#5029) Keep existing printable appearances upright on rotated source pages, including Text annotations whose NoRotate flag is clear. Preserve original flags and appearance streams, print eligibility and outer page transforms. * M10: clarify NoRotate verification docs --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- ROADMAP.md | 9 + crates/print/README.md | 5 +- crates/print/src/imposition.rs | 23 +++ crates/print/src/tests/appearances.rs | 266 +++++++++++++++++++++++++- docs/roadmap.md | 9 + parity/acrobat-features.toml | 8 +- 6 files changed, 314 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03f730704e..ae6297e910 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -252,6 +252,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. +- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear. + Print eligibility, source dictionaries and shared appearance streams remain unchanged. + +- **2026-10-27 (M10, #5029):** Vector printing retains the orientation and upper-left anchor of existing NoRotate annotation appearances on rotated source pages. + Crop origins, UserUnit and later sheet transforms remain effective; estimates unchanged. + +- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices. + Synthetic render and save/reopen regressions cover the change; estimates unchanged. + - **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. - **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged. diff --git a/crates/print/README.md b/crates/print/README.md index d4478fcbf5..7b0fd93f2a 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -55,6 +55,9 @@ annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices. +Existing printable NoRotate appearances keep their orientation and upper-left rectangle anchor when the source page is rotated. +Text annotations use this upright placement even with the NoRotate flag clear (ISO 32000-1 section 12.5.6.4). +Crop origins, UserUnit, sheet scaling, and later imposition rotations still apply; NoZoom does not cancel printing scale. Missing or unusable appearance streams still cannot be printed. The GUI Print dialog remembers its last selected paper size across application sessions, @@ -108,7 +111,7 @@ In a browser there is no spooler: the app (`pdfcraft-ui-egui`'s `web_print`) dra PDF's sheets with PdfCraft's renderer and prints them through the browser's print dialog, which sets copies, two-sided printing and colour. This crate stays free of web APIs. -Not yet: print as image, poster labels, PostScript output, colour conversion for +Not yet: poster labels, PostScript output, colour conversion for grayscale. ## Architectural sheets and scale diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index b59e620328..d9f1e32043 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -163,6 +163,20 @@ pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Some(a) } +/// ISO 32000-1 section 12.5.3: counterrotate the fitted appearance about Rect's upper-left +/// corner in default user space. Crop, UserUnit and sheet transforms remain outside it. +fn no_rotate_placement(placement: Matrix, rect: [f64; 4], rotation: i64) -> Option { + let [x, _, _, y] = rect; + let counterrotation = match rotation { + 90 => Matrix([0.0, 1.0, -1.0, 0.0, x + y, y - x]), + 180 => Matrix([-1.0, 0.0, 0.0, -1.0, 2.0 * x, 2.0 * y]), + 270 => Matrix([0.0, -1.0, 1.0, 0.0, x - y, y + x]), + _ => Matrix::default(), + }; + let placement = placement.then(&counterrotation); + placement.0.iter().all(|value| value.is_finite()).then_some(placement) +} + /// A Form XObject holding page `index` as it prints (user space; BBox = crop box). /// `pages` are the document's pages, listed once by the caller: listing them for each page /// printed would make printing quadratic in the page count. @@ -209,6 +223,15 @@ pub(crate) fn page_form( let Some(ap) = appearance(doc, &d) else { continue }; let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + // Text annotations have implicit NoRotate even when the flag is clear (ISO 32000-1 section 12.5.6.4). + let no_rotate = flags & 16 != 0 || d.get(b"Subtype").is_some_and(|subtype| doc.resolve(subtype).as_name() == Some(b"Text")); + let m = if no_rotate { + no_rotate_placement(m, rect, page.rotation(doc)) + .ok_or_else(|| PrintError::Invalid(format!("page {} has a non-finite NoRotate annotation placement", index.saturating_add(1))))? + } else { + m + }; let ap = if !form.contains(b"Subtype") && let Object::Stream(stream) = &*doc.get(ap) { diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index bcb0dd60e5..90fd861e3f 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -56,6 +56,11 @@ fn render(bytes: Vec) -> RenderedPage { } fn render_page(bytes: Vec, index: usize) -> RenderedPage { + render_page_at_size(bytes, index, (100, 100)) +} + +/// Explicit raster dimensions expose `/UserUnit` and sheet scaling without a default-size render hiding them. +fn render_page_at_size(bytes: Vec, index: usize, size: (u32, u32)) -> RenderedPage { let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { page: index, kind: RequestKind::Pixels, @@ -66,7 +71,7 @@ fn render_page(bytes: Vec, index: usize) -> RenderedPage { }); assert_eq!(page.error, None); assert!(page.warnings.is_empty(), "{:?}", page.warnings); - assert_eq!((page.width, page.height), (100, 100)); + assert_eq!((page.width, page.height), size); page } @@ -593,6 +598,7 @@ fn annotation_placement_preserves_small_transform_coefficients() { } } +/// Compare bad-geometry output with a no-annotation control to ensure page artwork remains visible. #[test] fn unusable_appearance_geometry_keeps_page_artwork() { let (mut control, _, _) = widget_fixture(Some(Object::name("Form")), None); @@ -815,3 +821,261 @@ fn shared_promotions_span_pages_without_merging_distinct_sources() { } } } + +const CORNER_COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 0, 255], [255, 255, 0, 255]]; + +// Contributor-original, asymmetric 20 x 10 artwork: red/blue above green/yellow. +/// Shift `/CropBox` and `/Rect` together so the colored, asymmetric artwork exposes origin and rotation errors. +fn no_rotate_fixture(rotation: Object, origin: [f64; 2], subtype: &str, flags: i64) -> (Document, ObjRef, ObjRef) { + let (mut doc, ap, annotation) = widget_fixture(Some(Object::name("Form")), None); + let form = doc.get(ap).as_dict().unwrap().clone(); + doc.set( + ap, + Object::Stream(Stream::flate(form, b"0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f")), + ); + let [x, y] = origin; + doc.update_dict(annotation, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"F".to_vec(), Object::Int(flags)); + d.set(b"Rect".to_vec(), numbers(&[x + 40.0, y + 40.0, x + 60.0, y + 50.0])); + }) + .unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[x - 20.0, y - 20.0, x + 120.0, y + 120.0])); + d.set(b"CropBox".to_vec(), numbers(&[x, y, x + 100.0, y + 100.0])); + d.set(b"Rotate".to_vec(), rotation); + d.remove(b"Contents"); + }) + .unwrap(); + (doc, ap, annotation) +} + +/// Colored quadrant samples distinguish every quarter-turn; white margins expose extent or anchor errors. +fn assert_upright_corners(page: &RenderedPage, anchor: (usize, usize), scale: usize) { + let (x, y) = anchor; + for ((dx, dy), color) in [(5, 2), (15, 2), (5, 7), (15, 7)].into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(page, x + dx * scale, y + dy * scale), color, "upright corner at {anchor:?}, scale={scale}"); + } + for (px, py) in [(x - 2, y + 5 * scale), (x + 22 * scale, y + 5 * scale), (x + 10 * scale, y - 2), (x + 10 * scale, y + 12 * scale)] { + assert_eq!(pixel(page, px, py), [255, 255, 255, 255], "outside the upright appearance at ({px},{py})"); + } +} + +/// Literal anchors and colors test pivot, orientation, and size without copying production math. +/// Direct and indirect `Text` subtypes with `/F 4` exercise implicit `NoRotate` without `/F 16`. +/// Unflagged non-`Text` cases retain the rotating control. +#[test] +fn vector_print_preserves_no_rotate_appearance_anchors() { + for (rotation, anchor, control) in [ + (0, (40, 50), [(45, 52), (55, 52), (45, 57), (55, 57)]), + (90, (50, 40), [(47, 45), (47, 55), (42, 45), (42, 55)]), + (180, (60, 50), [(55, 47), (45, 47), (55, 42), (45, 42)]), + (270, (50, 60), [(52, 55), (52, 45), (57, 55), (57, 45)]), + ] { + for (subtype, reversed, indirect_subtype) in [ + ("Widget", false, false), + ("Square", false, false), + ("Square", true, false), + ("Stamp", false, false), + ("Text", false, false), + ("Text", false, true), + ] { + for flags in [4, 20] { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags); + if indirect_subtype { + let name = doc.add(Object::name(subtype)); + doc.update_dict(annotation, |d| d.set(b"Subtype".to_vec(), Object::Ref(name))).unwrap(); + } + if reversed { + doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&[60.0, 50.0, 40.0, 40.0]))).unwrap(); + } + let before = saved(&doc); + let original = doc.get(ap); + let settings = print_settings(Content::DocumentAndMarkups); + let first = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(first.clone())).unwrap(); + for bytes in [first, impose(&reopened, &settings).unwrap()] { + let page = render(bytes); + if flags == 20 || subtype == "Text" { + assert_upright_corners(&page, anchor, 1); + } else { + for ((x, y), color) in control.into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(&page, x, y), color, "control: rotation={rotation}, subtype={subtype}"); + } + } + } + assert_eq!(doc.get(ap), original, "shared appearance bytes/dictionary stay unchanged"); + assert_eq!(saved(&doc), before, "rotation={rotation}, subtype={subtype}, flags={flags}"); + } + } + } +} + +/// Compare output pixel-for-pixel with the source shifted to its `/Rect` anchor. +/// This also verifies that the exposed page area remains white. +fn assert_translated_upright_source(actual: &RenderedPage, source: &RenderedPage, anchor: (i32, i32)) { + let (dx, dy) = (anchor.0 - 40, anchor.1 - 50); + for y in 0..100i32 { + for x in 0..100i32 { + let (sx, sy) = (x - dx, y - dy); + let expected = if (0..100).contains(&sx) && (0..100).contains(&sy) { pixel(source, sx as usize, sy as usize) } else { &[255; 4] }; + let actual = pixel(actual, x as usize, y as usize); + assert_eq!(actual, expected, "translated source at ({x},{y})"); + } + } +} + +/// Compare print output with a rotation-zero source under shifted `/BBox` and `/Matrix` transforms. +/// This also covers inherited and normalized `/Rotate` values. +#[test] +fn no_rotate_keeps_appearance_matrix_and_shifted_inherited_page_geometry() { + for (rotation, inherited, origin, anchor) in + [(450, true, [-20.0, 30.0], (50, 40)), (-180, false, [15.0, -25.0], (60, 50)), (-90, true, [-20.0, 30.0], (50, 60))] + { + for (matrix, shifted_bbox) in + [([2.0, 0.0, 0.0, 3.0, 7.0, -11.0], true), ([1.0, 0.0, 1.0, 1.0, 7.0, -11.0], false), ([-1.0, 0.0, 0.0, 1.0, 27.0, -11.0], false)] + { + let (mut doc, ap, _) = no_rotate_fixture(Object::Int(rotation), origin, "Widget", 20); + doc.update_dict(ap, |d| { + d.set(b"Matrix".to_vec(), numbers(&matrix)); + if shifted_bbox { + d.set(b"BBox".to_vec(), numbers(&[5.0, -4.0, 25.0, 6.0])); + } + }) + .unwrap(); + if shifted_bbox { + let form = doc.get(ap).as_dict().unwrap().clone(); + doc.set( + ap, + Object::Stream(Stream::flate( + form, + b"q 1 0 0 1 5 -4 cm 0 1 0 rg 0 0 20 10 re f 1 0 0 rg 0 5 10 5 re f 0 0 1 rg 10 5 10 5 re f 1 1 0 rg 10 0 10 5 re f Q", + )), + ); + } + let page = pdfcraft_model::pages(&doc)[0].obj; + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + let rotation = Object::Ref(doc.add(Object::Int(rotation))); + doc.update_dict(parent, |d| d.set(b"Rotate".to_vec(), rotation)).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Rotate"); + }) + .unwrap(); + } + let mut upright = doc.clone(); + upright.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(0))).unwrap(); + let expected = render(saved(&upright)); + // Establish that the reference contains real asymmetric artwork, rather than + // accepting two blank or uniformly colored renderings as equivalent. + for color in CORNER_COLORS { + assert!(expected.rgba.as_chunks::<4>().0.contains(&color)); + } + let before = saved(&doc); + let original = doc.get(ap); + let settings = print_settings(Content::Document); + let printed = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(printed.clone())).unwrap(); + assert_translated_upright_source(&render(printed), &expected, anchor); + assert_translated_upright_source(&render(impose(&reopened, &settings).unwrap()), &expected, anchor); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before, "rotation={rotation}, inherited={inherited}, origin={origin:?}, matrix={matrix:?}"); + } + } +} + +/// Invalid `/Rotate` values normalize to zero while the eligible appearance remains upright. +#[test] +fn malformed_page_rotation_keeps_no_rotate_appearance_upright() { + for rotation in [Object::Int(135), Object::Int(-45), Object::Real(90.5), Object::name("QuarterTurn")] { + let (doc, _, _) = no_rotate_fixture(rotation.clone(), [-20.0, 30.0], "Widget", 20); + assert_eq!(pdfcraft_model::pages(&doc)[0].rotation(&doc), 0); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert_upright_corners(&printed, (40, 50), 1); + assert_eq!(saved(&doc), before, "malformed Rotate={rotation:?}"); + } +} + +/// `NoZoom` does not cancel print layout scaling across `UserUnit` and custom sheet sizes. +#[test] +fn no_rotate_keeps_user_units_and_sheet_scaling() { + let (mut doc, _, annotation) = no_rotate_fixture(Object::Int(90), [-20.0, 30.0], "Widget", 28); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap(); + // Resolve the same flags as the existing print path; NoZoom does not cancel print scale. + let flags = doc.add(Object::Int(28)); + doc.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Ref(flags))).unwrap(); + let before = saved(&doc); + for (paper, layout, size, anchor, scale) in [ + ((200.0, 200.0), Layout::Size(SizeMode::Actual), (200, 200), (100, 80), 2), + ((100.0, 100.0), Layout::Size(SizeMode::Custom(50.0)), (100, 100), (50, 40), 1), + ] { + let settings = Settings { paper, layout, ..print_settings(Content::Document) }; + let printed = render_page_at_size(impose(&doc, &settings).unwrap(), 0, size); + assert_upright_corners(&printed, anchor, scale); + } + assert_eq!(saved(&doc), before); +} + +/// Counterrotating one use must not alter the shared stream or rotate its later unflagged use. +#[test] +fn no_rotate_is_per_invocation_when_appearances_are_shared() { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20); + let mut control = doc.get(annotation).as_dict().unwrap().clone(); + control.set(b"F".to_vec(), Object::Int(4)); + control.set(b"Rect".to_vec(), numbers(&[10.0, 10.0, 30.0, 20.0])); + let control = doc.add(Object::Dict(control)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annotation), Object::Ref(control)]))).unwrap(); + let before = saved(&doc); + let original = doc.get(ap); + let output = impose(&doc, &print_settings(Content::Document)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + let rendered = render(output); + assert_upright_corners(&rendered, (50, 40), 1); + for ((x, y), color) in [(17, 15), (17, 25), (12, 15), (12, 25)].into_iter().zip(CORNER_COLORS) { + assert_eq!(pixel(&rendered, x, y), color, "unflagged use of the shared appearance"); + } + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + assert_eq!(xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0"), xobject(&reopened, form.as_dict().unwrap(), b"PCPrA1")); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); +} + +/// Refuse non-finite `NoRotate` placement without changing the source. +/// Excluded and degenerate appearances retain their omission behavior. +#[test] +fn no_rotate_refuses_nonfinite_placement_and_still_skips_degenerate_appearances() { + for (rotation, rect) in [(90, [1e308, 0.0, 1.7e308, 1e308]), (180, [1e308, 0.0, 1.7e308, 1e308]), (270, [-1e308, 0.0, -0.3e308, 1e308])] { + for (subtype, flags) in [("Widget", 20), ("Text", 4)] { + let (mut doc, ap, annotation) = no_rotate_fixture(Object::Int(rotation), [0.0, 0.0], subtype, flags); + doc.update_dict(annotation, |d| d.set(b"Rect".to_vec(), numbers(&rect))).unwrap(); + let before = saved(&doc); + let original = doc.get(ap); + assert!( + matches!(impose(&doc, &print_settings(Content::DocumentAndMarkups)), Err(PrintError::Invalid(_))), + "rotation={rotation}, subtype={subtype}, flags={flags}: unrepresentable placement" + ); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); + let mut excluded = doc.clone(); + for flags in [16, 22] { + excluded.update_dict(annotation, |d| d.set(b"F".to_vec(), Object::Int(flags))).unwrap(); + let printed = render(impose(&excluded, &print_settings(Content::DocumentAndMarkups)).unwrap()); + assert!(printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]), "excluded malformed appearance"); + } + } + } + let (mut doc, ap, _) = no_rotate_fixture(Object::Int(90), [0.0, 0.0], "Widget", 20); + doc.update_dict(ap, |d| d.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 0.0, 10.0]))).unwrap(); + let before = saved(&doc); + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + assert!( + printed.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255, 255, 255, 255]), + "degenerate appearance keeps its existing omission behavior" + ); + assert_eq!(saved(&doc), before); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 11c7fcf655..90770f1dac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,8 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M10 #5029: Text appearances have implicit NoRotate during vector printing even when the flag is clear; estimates unchanged) +> +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged) > @@ -261,6 +264,10 @@ 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.
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. | | 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: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly).
Done since (#4088): automatic AcroForm field scripts ignore Print with a console diagnostic; explicit button and console printing remain available. Integrated main f137a289 (0.16.1) on 2026-10-31; ShowPanel/SaveAs filtering, all three engine regressions, the headless regression, field effects and document-error provenance are retained. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. | | 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). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Appearance placement retains small transform coefficients for large bounding boxes and matrices. Existing printable NoRotate appearances retain orientation and the upper-left rectangle anchor through source page rotation, including implicit NoRotate on Text annotations with the flag clear; later sheet transforms still apply (#5029). | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | @@ -340,6 +347,8 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M10 #5029: existing Text appearances keep their implicit NoRotate orientation in vector printing even when the flag is clear. Print eligibility and source/AP objects remain unchanged; native viewing without the flag remains a separate gap. Estimates unchanged. | +| 2026-10-27 | minor | M10 #5029: vector printing preserves existing NoRotate appearance orientation and anchors; estimates unchanged. Missing appearances and NoZoom print behavior remain unchanged. | | 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. | | 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index eced73a64a..4587e91425 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3686,8 +3686,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] -notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page." +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster/tests.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors"] +notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom). Vector printing preserves existing printable Text appearances upright even when NoRotate is implicit and the flag is clear; imported Text appearances with that flag clear still turn in the native view. Missing print appearances are not synthesized. Edit flattening is tracked separately." [[feature]] id = "comment.highlight" @@ -8345,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"] -notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path." +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::vector_print_preserves_no_rotate_appearance_anchors", "crates/print/src/tests/appearances.rs::no_rotate_keeps_user_units_and_sheet_scaling"] +notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Existing NoRotate appearances retain orientation and the rectangle upper-left anchor through source page rotation, including Text annotations with implicit NoRotate when the flag is clear; CropBox, UserUnit, sheet scaling and later imposition rotation still apply. NoZoom does not cancel print scale. Missing or unusable appearance streams remain outside this path." [[feature]] id = "print.print-with-comment-summary" From fe70ecce73bd982e80887a4d70fdc3f3913e21e4 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:31:12 +0100 Subject: [PATCH 7/7] Print in grayscale prints on a Windows driver with no grey option (#5806) (#6863) * Print in grayscale prints on a Windows driver with no grey option (#5806) Windows refused Print in grayscale ("the printer's driver did not retain the requested grayscale; nothing was sent") on drivers that list no grey option the ticket can name: Microsoft Print to PDF, and an HP Photosmart driver whose grey is its own vendor setting. - On Windows, a grayscale job's copy for the spooler is made grey first with Convert colours (all pages, comments and form fields, black kept black, pictures re-encoded where they can be), and the job says so (Job::grey_pages). The driver is still asked for grey where it has it; one without prints the grey pages instead of refusing them. A copy that can't be read goes as it is and asks the driver strictly, as before. - craft-print 970644b7 (storytold/craft-libs#774) adds Job::grey_pages and asks a driver that lists only psk:Grayscale for that, not psk:Monochrome. - Both the Print dialog and doc_print go through spooler_copy, which now takes the job. * craft-print 1146cdc4: craft-libs main with #774 (grey pages) and #773 (the CUPS printer list waits at most 15 s, storytold/pdfcraft#6761) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/automation/src/printing.rs | 6 +- crates/engine/src/spooler.rs | 133 ++++++++++++++++-- crates/ui-egui/src/workflows/print_ui.rs | 8 +- .../ui-egui/src/workflows/print_ui/draft.rs | 2 + 6 files changed, 135 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aef1801a53..1c64e1f20c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" +source = "git+https://github.com/storytold/craft-libs?rev=1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c#1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c" dependencies = [ "craft-winprint-gdi", "log", diff --git a/Cargo.toml b/Cargo.toml index aaafc8805a..e6f29521ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "1146cdc4f92e6c5c4fc3d67efbf0e1cec436a10c" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index ca423b194b..1e1f6e5fe0 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -106,6 +106,8 @@ fn spool_job( duplex, scaling: spool::Scaling::Exact, grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster), + // Set when the copy sent is made grey (`spooler_copy`, #5806). + grey_pages: false, title, options: { let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect(); @@ -310,8 +312,8 @@ impl Automation { } (None, Some(printer)) => { let printer = asked.as_ref().map_or(printer, |p| p.name.as_str()); - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; - let bytes = pdfcraft_engine::spooler::spooler_copy(bytes); + let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; + let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job); out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), diff --git a/crates/engine/src/spooler.rs b/crates/engine/src/spooler.rs index d3fbecd339..56347d8f09 100644 --- a/crates/engine/src/spooler.rs +++ b/crates/engine/src/spooler.rs @@ -6,20 +6,65 @@ use pdfcraft_cos::{SaveOptions, write_full}; use crate::guard; -/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler. -/// On Windows, whose PDF renderer prints an image of more than 2²² pixels from a shrunk copy, -/// large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131), so that a -/// scan prints at the printer's resolution. Elsewhere, with no large image, and when the copy -/// can't be read or written, the bytes go as they are: tiling never stops a print. -pub fn spooler_copy(bytes: Vec) -> Vec { +/// The print-ready PDF `bytes` ([`crate::Session::print_pdf`]) as they go to the system spooler +/// with `job`. Two things happen on Windows only; elsewhere the bytes go as they are. +/// +/// - A job in grayscale is made grey here first ([`greyed`]), and `job.grey_pages` says so. +/// Windows has no colour conversion of its own, and a driver that lists no grey option (Microsoft +/// Print to PDF, or one with only a vendor's own grey setting) refused the job outright +/// (#5806). The driver is still asked for grey where it has it. +/// - Large images are drawn as tiles ([`pdfcraft_optimize::tile_large_images`], #5131): Windows' +/// PDF renderer prints an image of more than 2²² pixels from a shrunk copy, so a scan comes out +/// at the printer's resolution. +/// +/// When the copy can't be read or written, the bytes go as they are: neither step ever stops a +/// print, and a grayscale job whose pages couldn't be made grey keeps asking the driver for grey, +/// strictly, as before. +pub fn spooler_copy(bytes: Vec, job: &mut crate::print::spool::Job) -> Vec { if !cfg!(windows) { return bytes; } - let bytes = Arc::new(bytes); + let mut bytes = Arc::new(bytes); + if job.grayscale { + match greyed(bytes.clone()) { + Some(Some(grey)) => { + bytes = Arc::new(grey); + job.grey_pages = true; + } + Some(None) => job.grey_pages = true, + None => {} + } + } // Once `tiled` returns, its document is gone and `bytes` is the only owner left: no copy. tiled(bytes.clone()).unwrap_or_else(|| Arc::try_unwrap(bytes).unwrap_or_else(|shared| (*shared).clone())) } +/// `bytes` with every colour on every page, its comments and form fields included, turned grey by +/// Convert colours (black kept black, pictures re-encoded where they can be): `Some(Some(_))`. +/// `Some(None)` when nothing needed converting, and `None` when the copy can't be read or written. +/// +/// Convert colours leaves shadings, patterns, inline images and pictures it can't re-encode +/// (indexed, CMYK, JPEG 2000 …) as they are; the driver is still asked for grey, so a driver with a +/// grey option makes those grey too, and one without prints them as they are. +pub fn greyed(bytes: Arc>) -> Option>> { + guard(|| { + let mut doc = pdfcraft_cos::Document::open(bytes).ok()?; + let pages: Vec = (0..pdfcraft_model::pages(&doc).len()).collect(); + let settings = + pdfcraft_edit::ConvertColours { target: pdfcraft_edit::ColourTarget::Gray, preserve_black: true, images: true, annotations: true }; + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, _: pdfcraft_edit::ColourTarget| { + pdfcraft_optimize::recolour_image(doc, s, pdfcraft_optimize::ImageTarget::Gray) + }; + let done = pdfcraft_edit::convert_colours(&mut doc, &pages, &settings, &mut recolour).ok()?; + if done.colours == 0 && done.images == 0 { + return Some(None); + } + write_full(&doc, &SaveOptions::default()).ok().map(Some) + }) + .ok() + .flatten() +} + /// `bytes` with their large images drawn as tiles, or `None` when there is none, or the copy /// can't be read or written. pub fn tiled(bytes: Arc>) -> Option> { @@ -37,6 +82,7 @@ mod tests { use pdfcraft_cos::{Dict, Document, Object, Stream}; use super::*; + use crate::print::spool::Job; /// A one-page PDF drawing a `w` × `h` grey image over the page. fn page_with_image(w: i64, h: i64) -> Vec { @@ -90,16 +136,83 @@ mod tests { fn a_print_with_nothing_to_tile_or_unreadable_goes_as_it_is() { assert_eq!(tiled(Arc::new(page_with_image(100, 100))), None); assert_eq!(tiled(Arc::new(b"not a PDF".to_vec())), None); - assert_eq!(spooler_copy(b"not a PDF".to_vec()), b"not a PDF"); + let mut job = Job::default(); + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut job), b"not a PDF"); let small = page_with_image(100, 100); - assert_eq!(spooler_copy(small.clone()), small); + assert_eq!(spooler_copy(small.clone(), &mut job), small); + assert!(!job.grey_pages); + // A grayscale job whose copy can't be read asks the driver for grey as before. + let mut grey = Job { grayscale: true, ..Job::default() }; + assert_eq!(spooler_copy(b"not a PDF".to_vec(), &mut grey), b"not a PDF"); + assert!(!grey.grey_pages); + } + + /// A one-page PDF filling the page in red. + fn red_page() -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1 0 0 rg 0 0 612 792 re f".to_vec()))); + let mut page = Dict::new(); + page.set("Type", Object::name("Page")); + page.set("Parent", Object::Ref(pages)); + page.set("MediaBox", Object::Array([0, 0, 612, 792].map(Object::Int).to_vec())); + page.set("Contents", Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + d.set("Count", Object::Int(1)); + d.set("Kids", Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + } + + /// The operators of the first page's content. + fn first_page_content(bytes: Vec) -> String { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap(); + match &*doc.get(contents) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + other => panic!("{other:?}"), + } + } + + /// #5806: a page in colour is made grey for a grayscale job, which says so. + #[test] + fn a_colour_page_is_made_grey_for_a_grayscale_job() { + let grey = greyed(Arc::new(red_page())).expect("readable").expect("converted"); + let content = first_page_content(grey.clone()); + assert!(!content.contains(" rg"), "{content}"); + assert!(content.contains(" g"), "{content}"); + // Already grey: nothing to convert, and the pages are still grey. + assert_eq!(greyed(Arc::new(grey)), Some(None)); + assert_eq!(greyed(Arc::new(b"not a PDF".to_vec())), None); + } + + /// On Windows a grayscale job's copy is grey and the job says its pages are; elsewhere the + /// bytes go as they are and the driver is asked, as before. A colour job is never converted. + #[test] + fn a_grayscale_job_sends_grey_pages_on_windows_only() { + let red = red_page(); + let mut job = Job { grayscale: true, ..Job::default() }; + let sent = spooler_copy(red.clone(), &mut job); + if cfg!(windows) { + assert!(job.grey_pages); + assert!(!first_page_content(sent).contains(" rg")); + } else { + assert!(!job.grey_pages); + assert_eq!(sent, red); + } + let mut colour = Job::default(); + assert_eq!(spooler_copy(red.clone(), &mut colour), red); + assert!(!colour.grey_pages); } /// On Windows the spooler gets the tiled copy ([`tiled`]); elsewhere the bytes as they are. #[test] fn the_spooler_gets_tiles_on_windows_only() { let large = page_with_image(2100, 2100); - let sent = spooler_copy(large.clone()); + let sent = spooler_copy(large.clone(), &mut Job::default()); if cfg!(windows) { assert_eq!(Some(&sent), tiled(Arc::new(large.clone())).as_ref()); assert_ne!(sent, large); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index c5b90ec12a..c220a5bd2f 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -557,7 +557,7 @@ impl PdfCraftApp { /// the progress card counts the sheets as they reach the spooler, and the spooler's answer /// arrives as a notice. #[cfg(not(target_arch = "wasm32"))] - fn send_to_printer(&mut self, printer: String, bytes: Vec, job: spool::Job) -> bool { + fn send_to_printer(&mut self, printer: String, bytes: Vec, mut job: spool::Job) -> bool { let (tx, rx) = std::sync::mpsc::channel(); let ctx = self.ctx.clone(); let spooler = self.spooler.clone(); @@ -567,9 +567,9 @@ impl PdfCraftApp { ctx.request_repaint(); } }; - // Large pictures become tiles on Windows (#5131): work on the whole file, kept off the - // UI thread with the rest. - let bytes = pdfcraft_engine::spooler::spooler_copy(bytes); + // On Windows grayscale makes the pages grey (#5806) and large pictures become tiles + // (#5131): work on the whole file, kept off the UI thread with the rest. + let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job); // The receiver may be gone (the app quit): nothing to report to then. let result = match &spooler { Some(submit) => submit(&bytes, &job), diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index cf65351141..5b02687b62 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -341,6 +341,8 @@ impl PrintDraft { }, scaling: spool::Scaling::Exact, grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()), + // Set when the copy sent is made grey (`spooler_copy`, #5806). + grey_pages: false, title: title.to_string(), // Only for the printer the options were read from. options: match (&self.printer, &self.driver_options) {