From bf86689fd7e930bae1b08d4b91f4b11bc8ecde9f Mon Sep 17 00:00:00 2001 From: Kyle Kassen <85040622+KyleKassen@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:58:16 -0500 Subject: [PATCH] M11: measure optimizer image resolution in physical page units --- ROADMAP.md | 7 +++- contributors/people.toml | 3 ++ crates/optimize/README.md | 4 +- crates/optimize/src/images.rs | 9 ++-- crates/optimize/tests/optimize.rs | 68 +++++++++++++++++++++++++++++++ docs/roadmap.md | 8 +++- parity/acrobat-features.toml | 4 +- 7 files changed, 95 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf2..f0a97f79a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -191,6 +191,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms. + Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults. + Estimates unchanged. + - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -640,6 +644,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | diff --git a/contributors/people.toml b/contributors/people.toml index 4f2b22775..e83144482 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -176,3 +176,6 @@ real_name = "Nick Nyssen" [people.agent-reed] sync_github_name = true + +[people.KyleKassen] +real_name = "Kyle Kassen" diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3..bce152373 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -21,7 +21,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary policy, so trailing comments, split operands and graphics state keep their meaning after merging. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM - through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be + through form XObjects, up to 12 deep; the smallest over all uses). + Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches. + If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 896542285..d66cc3318 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -158,15 +158,18 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap Stream { } } +#[test] +fn image_resolution_uses_the_pages_physical_unit() { + for (unit, scale) in [ + (None, 1.0), + (Some(Object::Int(10)), 10.0), + (Some(Object::Real(2.5)), 2.5), + (Some(Object::Int(0)), 1.0), + (Some(Object::Int(-10)), 1.0), + (Some(Object::Real(0.5)), 1.0), + (Some(Object::name("Ten")), 1.0), + (Some(Object::Int(100_000)), pdfcraft_model::MAX_USER_UNIT), + ] { + for indirect in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 1, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + let parent = doc.get(p).as_dict().unwrap().reference(b"Parent").unwrap(); + // UserUnit is page-local, unlike Resources and MediaBox. + doc.update_dict(parent, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + if let Some(unit) = unit.clone() { + let unit = if indirect { Object::Ref(doc.add(unit)) } else { unit }; + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), unit)).unwrap(); + } + let ppi = effective_resolutions(&doc, &[p])[&img]; + assert!((ppi - 720.0 / scale).abs() < 1e-9, "unit={unit:?}, indirect={indirect}: {ppi}"); + } + } +} + +#[test] +fn optimization_retains_an_image_below_threshold_on_a_scaled_page() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + // 720 pixels across ten inches is 72 ppi, already below the 225 ppi threshold. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + let image = stream(&doc, img); + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(720), Some(720))); + let reopened = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let dimensions: Vec<_> = reopened + .scan_objects() + .filter_map(|(_, object)| match &*object { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect(); + assert_eq!(dimensions, [(Some(720), Some(720))]); +} + #[test] fn images_are_measured_where_drawn_and_downsampled() { let mut doc = Document::new_empty(); @@ -925,6 +976,23 @@ fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { inner } +#[test] +fn shared_forms_are_measured_in_each_pages_physical_units() { + for reverse in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let outer = nested_forms(&mut doc, img, 3); + let normal = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + let scaled = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + doc.update_dict(scaled, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + let pages = if reverse { [scaled, normal] } else { [normal, scaled] }; + assert_eq!(effective_resolutions(&doc, &pages)[&img], 72.0, "reverse={reverse}"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(720)); + } +} + #[test] fn a_deeply_nested_form_is_measured_at_its_largest_use() { // 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled. diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc..3802177fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) + > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) @@ -185,7 +187,7 @@ total matches the parity estimate. | 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. 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`. 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. | -| 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). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. 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 | +| 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). 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. 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). | @@ -233,6 +235,10 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. +Synthetic measurement and optimization regressions cover the change. +Estimates unchanged. + **2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged. **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c..b339d3c86 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7301,8 +7301,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] id = "optimize.recompress-images"