collective

Browse / github.com/storytold/pdfcraft / patch

M10: preserve device JPEG Decode during image export

M10: preserve device JPEG Decode during image export ## What this changes A DeviceGray JPEG with `/Decode [1 0]` can render as gray 191 on the page while Export all images and Save Image As write the original gray-64 JPEG. These export paths now apply valid per-component Decode intervals for named DeviceGray/DeviceRGB JPEGs and produce PNG when the mapping changes their samples. Default and identity mappings retain the original JPEG bytes when no other conversion is needed. The mapping accepts indirect color-space names, arrays and endpoints, preserves supported soft/stencil/color-key masks, compares color keys against raw samples, and applies

Status
open
Score
0.00
Adopters
0 other operators
Author
KyleKassen (imported from pull request #4992, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+720 −59 in 18 files
Review
pending
Shared
2026-10-28
Id
787db03fe316fdda7bf8ddd0

How it went

No other operator has reported using it yet.

Diff

raw · ATTRIBUTION.md ATTRIBUTION.toml Cargo.lock Cargo.toml ROADMAP.md crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/create/README.md crates/create/src/extract.rs crates/create/src/tests.rs crates/engine/src/copy_image.rs crates/engine/src/export.rs crates/ocr/Cargo.toml crates/ocr/src/lib.rs docs/releasing.md docs/roadmap.md packaging/windows/test-msi.ps1 parity/acrobat-features.toml

diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md--- a/ATTRIBUTION.md+++ b/ATTRIBUTION.md@@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) |-| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |-| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |+| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |+| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |  ## Optional build inputs (1) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml--- a/ATTRIBUTION.toml+++ b/ATTRIBUTION.toml@@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"  [[fetched]]-file = "text-detection.rten"-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"+file = "text-detection.onnx"+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)"-sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"+sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe"  [[fetched]]-file = "text-recognition.rten"-url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"+file = "text-recognition.onnx"+url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)"-source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)"+source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)"-sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"+sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"  [[build_input]] name = "craft-fonts"diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1174,7 +1174,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-keymap" version = "0.1.0"-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [  "egui",  "serde",@@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0"-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [  "craft-keymap",  "craft-quit",@@ -1210,7 +1210,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0"-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [  "dispatch2",  "egui",@@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0"-source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"+source = "git+https://github.com/storytold/craft-libs?rev=acab34c0d8d84363ea3d05885f1a15601c233a19#acab34c0d8d84363ea3d05885f1a15601c233a19" dependencies = [  "egui",  "log",@@ -4340,6 +4340,7 @@ dependencies = [  "ocrs",  "pdfcraft-fonts",  "rten",+ "tempfile",  "thiserror 2.0.21", ] @@ -5473,6 +5474,7 @@ dependencies = [  "rten-base",  "rten-gemm",  "rten-model-file",+ "rten-onnx",  "rten-parallel",  "rten-shape-inference",  "rten-simd",@@ -5522,6 +5524,12 @@ dependencies = [  "rten-base", ] +[[package]]+name = "rten-onnx"+version = "0.26.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e"+ [[package]] name = "rten-parallel" version = "0.26.0"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -13,12 +13,12 @@ repository = "https://github.com/storytold/pdfcraft"  [workspace.dependencies] # Shared frontend components; engines remain independent of egui.-craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.-craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }+craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere.-craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }-craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }+craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19" }+craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "acab34c0d8d84363ea3d05885f1a15601c233a19", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets).diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,6 +2,8 @@  **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10, #1201 partial: DeviceGray/DeviceRGB JPEG Decode is applied during image export; 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 (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)@@ -199,6 +201,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md  ## Progress log +- **2026-10-27 (M10, #1201 partial, JPEG Decode):** Export all images and Save Image As map valid nonidentity Decode intervals for named DeviceGray/DeviceRGB JPEGs to PNG, including indirect entries and supported transparency.+  Default/identity JPEGs keep their original bytes when no other conversion is needed; explicit DCT ColorTransform, corrupt data, mismatched dimensions or component counts, and excessive pixel counts refuse a required Decode conversion.+  ICC/calibrated conversion, arbitrary CMYK Decode, existing 16-bit precision limits and estimates are unchanged.+- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **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-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.@@ -682,6 +688,8 @@ Newest first: the date, what moved, and the new overall estimate.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M10, #1201 partial: image export maps valid nonidentity DeviceGray/DeviceRGB JPEG Decode to PNG, preserves supported masks and refuses required conversions it cannot safely perform. Default/identity JPEG paths, other color-space limits and 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-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/automation/src/tools.rs b/crates/automation/src/tools.rs--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -1241,7 +1241,7 @@ pub fn tools() -> Vec<ToolDef> {             .ro()             .cmd("ocr.recognize")             .with(schema(json!({}), &[])),-        t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")+        t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images retain their original bytes for default/identity Decode when no other conversion is needed; valid nonidentity Decode on named DeviceGray/DeviceRGB JPEGs produces PNG with mapped colors. Supported transparency stays as PNG alpha. Required Decode conversion refuses explicit DCT ColorTransform parameters, corrupt JPEG data, mismatched dimensions or component counts, and excessive pixel counts, reporting those images under skipped. Arbitrary ICC, calibrated and CMYK JPEG Decode remains limited. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")             .cmd("export.all_images")             .with(schema(                 json!({@@ -1352,7 +1352,7 @@ pub fn tools() -> Vec<ToolDef> {                 }),                 &["doc", "page", "image", "action"],             )),-        t("image_save", "Save image as", "Write one of a page's raster images (number from page_images; forms are refused) to path: JPEG images unchanged, others as PNG (the extension is added when missing).")+        t("image_save", "Save image as", "Write one of a page's raster images (number from page_images; forms are refused) to path: JPEG images retain their original bytes for default/identity Decode when no other conversion is needed; valid nonidentity Decode on named DeviceGray/DeviceRGB JPEGs produces PNG with mapped colors and supported transparency. Required Decode conversion refuses explicit DCT ColorTransform parameters, corrupt JPEG data, mismatched dimensions or component counts, and excessive pixel counts. Arbitrary ICC, calibrated and CMYK JPEG Decode remains limited. Other images are PNG; the extension is added when missing.")             .destructive()             .cmd("edit.edit_text")             .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])),diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -3460,6 +3460,121 @@ fn exporting_all_images_through_tools() {     assert_eq!(ok(&mut a, "doc_export_all_images", json!({ "doc": made, "folder": "imgs3", "min_size": 10000 }))["count"], 0); } +/// A source-generated JPEG in a drawn image, with optional Decode and a configurable PDF width.+fn jpeg_decode_tool_pdf(decode: Option<&[f64]>, width: i64) -> (Vec<u8>, Vec<u8>) {+    use pdfcraft_cos::{Dict, Document, Object, Stream};+    let mut jpeg = Vec::new();+    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode(&[64; 8], 4, 2, image::ExtendedColorType::L8).unwrap();+    let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap();+    let mut dict = Dict::from_iter([+        (b"Type".to_vec(), Object::name("XObject")),+        (b"Subtype".to_vec(), Object::name("Image")),+        (b"Width".to_vec(), Object::Int(width)),+        (b"Height".to_vec(), Object::Int(2)),+        (b"BitsPerComponent".to_vec(), Object::Int(8)),+        (b"ColorSpace".to_vec(), Object::name("DeviceGray")),+        (b"Filter".to_vec(), Object::name("DCTDecode")),+    ]);+    if let Some(values) = decode {+        dict.set(b"Decode".to_vec(), Object::Array(values.iter().copied().map(Object::Real).collect()));+    }+    let image = cos.add(Object::Stream(Stream::from_raw(dict, jpeg.clone())));+    let content = cos.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 40 0 0 20 20 30 cm /Im Do Q".to_vec())));+    let page = pdfcraft_model::pages(&cos)[0].obj;+    cos.update_dict(page, |dict| {+        dict.set(b"Contents".to_vec(), Object::Ref(content));+        dict.set(+            b"Resources".to_vec(),+            Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Im".to_vec(), Object::Ref(image))])))])),+        );+    })+    .unwrap();+    (pdfcraft_cos::write_full(&cos, &Default::default()).unwrap(), jpeg)+}++/// The original encoded JPEG and Decode entry, independently of object renumbering on save.+fn jpeg_decode_tool_snapshot(bytes: Vec<u8>) -> (Vec<u8>, Option<pdfcraft_cos::Object>) {+    use pdfcraft_cos::{Document, Object};+    let cos = Document::open(std::sync::Arc::new(bytes)).unwrap();+    let page = pdfcraft_model::pages(&cos)[0].clone();+    let res = cos.resolve(page.dict.get(b"Resources").unwrap());+    let xo = cos.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());+    let image = cos.resolve(xo.as_dict().unwrap().get(b"Im").unwrap());+    let Object::Stream(image) = &*image else { panic!("fixture image") };+    (image.raw.to_vec(), image.dict.get(b"Decode").cloned())+}++/// Export All Images and Save Image As keep decoded JPEG appearance without modifying source data.+#[test]+fn jpeg_decode_exports_and_image_save_follow_page_colors() {+    let dir = workdir("jpeg-decode-export");+    let mut a = auto(&dir);+    for (name, decode) in [("default", None), ("inverted", Some([1., 0.])), ("scaled", Some([0.25, 0.75]))] {+        let (source, jpeg) = jpeg_decode_tool_pdf(decode.as_ref().map(|values| values.as_slice()), 4);+        let input = format!("{name}.pdf");+        std::fs::write(dir.join(&input), &source).unwrap();+        let doc = ok(&mut a, "doc_open", json!({"path":input}))["doc"].as_u64().unwrap();+        let snapshot = jpeg_decode_tool_snapshot(source.clone());+        let saved_path = format!("{name}-reopened.pdf");+        for pass in ["before", "reopened"] {+            let current = if pass == "before" {+                doc+            } else {+                ok(&mut a, "doc_save", json!({"doc":doc,"path":saved_path}));+                ok(&mut a, "doc_open", json!({"path":saved_path}))["doc"].as_u64().unwrap()+            };+            let before = ok(&mut a, "doc_list", json!({}));+            let bulk = ok(&mut a, "doc_export_all_images", json!({"doc":current,"folder":format!("{name}-{pass}-all")}));+            assert_eq!(bulk["count"], 1);+            assert!(bulk["skipped"].as_array().unwrap().is_empty());+            let exported = std::fs::read(bulk["files"][0]["path"].as_str().unwrap()).unwrap();+            let path = format!("{name}-{pass}-single");+            let one = ok(&mut a, "image_save", json!({"doc":current,"page":1,"image":1,"path":path}));+            let extension = if decode.is_some() { "png" } else { "jpg" };+            assert_eq!(one["format"], extension);+            assert_eq!(std::fs::read(dir.join(format!("{path}.{extension}"))).unwrap(), exported);+            if let Some([lo, hi]) = decode {+                let sample = image::load_from_memory(&jpeg).unwrap().into_luma8().get_pixel(0, 0)[0];+                let expected = (lo * f64::from(255 - sample) + hi * f64::from(sample)).round() as u8;+                let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap();+                let mut pixels = vec![0; reader.output_buffer_size().unwrap()];+                let frame = reader.next_frame(&mut pixels).unwrap();+                assert_eq!(frame.color_type, png::ColorType::Rgb);+                assert_eq!(&pixels[..frame.buffer_size()], &[expected; 24]);+            } else {+                assert_eq!(exported, jpeg, "default JPEG remains byte-for-byte unchanged");+            }+            assert_eq!(ok(&mut a, "doc_list", json!({})), before, "exports create no edit or history entry");+            assert_eq!(std::fs::read(dir.join(&input)).unwrap(), source);+        }+        assert_eq!(jpeg_decode_tool_snapshot(std::fs::read(dir.join(saved_path)).unwrap()), snapshot);+    }+    std::fs::remove_dir_all(dir).unwrap();+}++/// A required conversion that cannot match the declared dimensions must not overwrite a file.+#[test]+fn jpeg_decode_failed_transforms_preserve_destination() {+    let dir = workdir("jpeg-decode-refusal");+    let (source, _) = jpeg_decode_tool_pdf(Some(&[1., 0.]), 5);+    std::fs::write(dir.join("mismatch.pdf"), &source).unwrap();+    let mut a = auto(&dir);+    let doc = ok(&mut a, "doc_open", json!({"path":"mismatch.pdf"}))["doc"].as_u64().unwrap();+    let before = ok(&mut a, "doc_list", json!({}));+    let destination = b"existing destination must survive a refused transform";+    std::fs::write(dir.join("existing.png"), destination).unwrap();+    let error = a.call("image_save", &json!({"doc":doc,"page":1,"image":1,"path":"existing.png"})).unwrap_err();+    assert!(!error.to_string().is_empty());+    assert_eq!(std::fs::read(dir.join("existing.png")).unwrap(), destination);+    let bulk = ok(&mut a, "doc_export_all_images", json!({"doc":doc,"folder":"refused"}));+    assert_eq!(bulk["count"], 0);+    assert_eq!(bulk["skipped"].as_array().unwrap().len(), 1);+    assert!(std::fs::read_dir(dir.join("refused")).unwrap().next().is_none());+    assert_eq!(ok(&mut a, "doc_list", json!({})), before);+    assert_eq!(std::fs::read(dir.join("mismatch.pdf")).unwrap(), source);+    std::fs::remove_dir_all(dir).unwrap();+}+ #[test] fn fill_and_sign_through_tools() {     let dir = workdir("fill");diff --git a/crates/create/README.md b/crates/create/README.md--- a/crates/create/README.md+++ b/crates/create/README.md@@ -29,5 +29,11 @@ Indexed Decode values select palette entries after rounding and clipping. Color- Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. The existing 16-bit path still uses each sample's high byte.-JPEG image Decode handling remains limited.++JPEGs keep their original bytes for default or identity Decode when transparency or existing CMYK normalization does not already require PNG output.+For named DeviceGray and DeviceRGB JPEGs, valid nonidentity per-component Decode intervals produce PNG with the mapped colors and supported masks preserved.+The color-space name, Decode array and its endpoints may be indirect.+An explicit DCT ColorTransform parameter refuses this required conversion.+Corrupt JPEG data, mismatched dimensions or component counts, and excessive pixel counts also fail the required conversion, returning an error from Save Image As or a skipped-image reason from Export all images.+Arbitrary JPEG Decode for ICC, calibrated and CMYK color spaces remains outside this change. ICC transforms and calibrated color conversion remain outside this export path.diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs--- a/crates/create/src/extract.rs+++ b/crates/create/src/extract.rs@@ -1,6 +1,6 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages-//! use, as files. JPEG images are written unchanged; other images, and JPEG images with-//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour+//! use, as files. JPEG images are written unchanged unless DeviceGray/RGB Decode intervals,+//! transparency or CMYK sample polarity require a PNG. Other images are also written as PNG with their soft mask, stencil mask or colour //! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are //! reported, never silently left out. @@ -62,7 +62,7 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx     out } -/// One image XObject as a file: JPEG as is, else PNG ("Save image as").+/// One image XObject as a file: JPEG as is when its appearance permits, else PNG ("Save image as"). pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec<u8>), String> {     match &*doc.get(image) {         Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s),@@ -114,10 +114,26 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>         _ => {}     }     let as_is = || Ok(("jpg", s.raw.to_vec()));+    // Only device spaces have the unit component ranges this path can reproduce. Calibrated+    // and ICCBased JPEGs retain their existing handling rather than being mistaken for devices.+    let device_components = if jpeg {+        s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)).and_then(|cs| match cs.as_name() {+            Some(b"DeviceGray" | b"G") => Some(1),+            Some(b"DeviceRGB" | b"RGB") => Some(3),+            _ => None,+        })+    } else {+        None+    };+    let jpeg_decode = device_components.and_then(|n| decode(doc, s, n));+    let mapped_jpeg = jpeg_decode.as_ref().is_some_and(|pairs| pairs.iter().any(|pair| *pair != [0.0, 1.0]));+    if mapped_jpeg && unsupported_dct_parameters(doc, s) {+        return Err("JPEG Decode with ColorTransform or unresolved DCT parameters can't be exported yet".into());+    }     let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));     let Some((w, h)) = size(w, h) else {         return match () {-            _ if jpeg => as_is(),+            _ if jpeg && !mapped_jpeg => as_is(),             _ if w <= 0 || h <= 0 => Err("the image has no size".into()),             _ => Err(format!("{w} × {h} is too large to export")),         };@@ -126,8 +142,9 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>     // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).     let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };     let key = if mask { None } else { colour_key(doc, s) };-    // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.-    let inverted = (jpeg || mask)+    // Device JPEGs use complete, validated Decode pairs below. Do not apply a malformed+    // array's first pair as a second, blanket inversion when transparency requires a PNG.+    let inverted = ((jpeg && device_components.is_none()) || mask)         && s.dict             .get(b"Decode")             .map(|d| doc.resolve(d))@@ -137,14 +154,19 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>     // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as     // a negative.     let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk));-    if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {+    if jpeg && alpha.is_none() && key.is_none() && !as_is_negative && !mapped_jpeg {         return as_is();     }     let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };     let (samples, bpc) = if jpeg {+        if mapped_jpeg && !device_jpeg_components(&s.raw, &space, w, h) {+            return Err("the JPEG can't be decoded at its declared size and device colour space for Decode mapping".into());+        }         // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an         // indexed one (not valid), is written as is, without its transparency.-        let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };+        let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else {+            return if mapped_jpeg { Err("the JPEG can't be decoded at its declared size for Decode mapping".into()) } else { as_is() };+        };         space = decoded;         (samples, 8)     } else {@@ -161,11 +183,17 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>         (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)     };     let n = space.components();-    let decode = if jpeg || mask { None } else { decode(doc, s, n) };+    let decode = if jpeg {+        jpeg_decode+    } else if mask {+        None+    } else {+        decode(doc, s, n)+    };     if alpha.is_none() {         alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));     }-    if jpeg && alpha.is_none() && !as_is_negative {+    if jpeg && alpha.is_none() && !as_is_negative && !mapped_jpeg {         return as_is();     }     let invert = inverted ^ mask; // a stencil mask paints its 0 samples@@ -214,7 +242,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>     png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } -/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90).+/// The explicit Decode intervals for an image's colour components (8.9.5.2, Table 90). /// /// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives /// the default mapping, so the image still exports.@@ -232,6 +260,34 @@ fn decode(doc: &Document, s: &Stream, n: usize) -> Option<Vec<[f64; 2]>> {     Some(pairs) } +/// The generic JPEG decoder cannot promise a PDF-level DCT override. Accept only parameters+/// we can inspect, so unresolved references and malformed containers cannot hide an override.+fn unsupported_dct_parameters(doc: &Document, s: &Stream) -> bool {+    let Some(value) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return false };+    let supported = |p: &Object| match p {+        Object::Null => true,+        Object::Dict(dict) => dict.get(b"ColorTransform").is_none(),+        _ => false,+    };+    match &*value {+        Object::Array(values) if values.len() == 1 => !values.first().is_some_and(|p| supported(&doc.resolve(p))),+        _ => !supported(&value),+    }+}++/// Reject inconsistent device declarations before applying component-specific intervals.+fn device_jpeg_components(data: &[u8], space: &Space, w: usize, h: usize) -> bool {+    use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions};+    let options = DecoderOptions::default().set_strict_mode(false).set_max_width(w).set_max_height(h);+    let mut decoder = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);+    if decoder.decode_headers().is_err() || decoder.dimensions() != Some((w, h)) {+        return false;+    }+    // APP14 can label a three-component RGB JPEG as CMYK until the decoder initializes+    // its components. The header's component count is authoritative at this stage.+    matches!((space, decoder.info().map(|info| info.components)), (Space::Gray, Some(1)) | (Space::Rgb, Some(3)))+}+ /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> {     let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);@@ -242,15 +298,20 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> { /// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, /// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {+    use image::ImageDecoder;     match space {         Space::Indexed(..) => return None,         Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),         _ => {}     }-    let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;-    if img.width() as usize != w || img.height() as usize != h {+    let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(data)).ok()?;+    let (width, height) = decoder.dimensions();+    if width as usize != w || height as usize != h {         return None;     }+    // Check the JPEG's own dimensions before allocating its decoded raster. PDF dimensions+    // have already passed size(), but an inconsistent JPEG header is untrusted too.+    let img = image::DynamicImage::from_decoder(decoder).ok()?;     Some(match space {         Space::Gray => (img.into_luma8().into_raw(), Space::Gray),         _ => (img.into_rgb8().into_raw(), Space::Rgb),diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs--- a/crates/create/src/tests.rs+++ b/crates/create/src/tests.rs@@ -1151,6 +1151,391 @@ fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() {     } } +/// A JPEG's PDF Decode mapping changes the exported pixels through both image-export APIs.+#[test]+fn jpeg_decode_inversion_changes_exported_pixels() {+    let jpeg = real_jpeg();+    let samples = image::load_from_memory(&jpeg).unwrap().into_luma8().into_raw();+    let expected: Vec<u8> = samples.into_iter().flat_map(|sample| [255 - sample; 3]).collect();+    let mut dict = image_dict(4, 2, "DeviceGray", 8);+    dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));+    dict.set(b"Decode".to_vec(), decode_array(&[1., 0.]));+    let (doc, image) = decode_fixture(dict, jpeg);+    let out = extract_images(&doc, &[0], 0);+    assert!(out.skipped.is_empty(), "{:?}", out.skipped);+    assert_eq!(out.images.len(), 1);+    assert_eq!(out.images[0].extension, "png", "an inverted JPEG cannot be exported unchanged");+    assert_eq!(exported_png_pixels(&out.images[0].data), expected);+    let (extension, bytes) = image_file(&doc, image).unwrap();+    assert_eq!(extension, "png");+    assert_eq!(exported_png_pixels(&bytes), expected);+}++/// Source-generated flat JPEGs keep expected colours independent of lossy encoder rounding.+fn jpeg_decode_fixture(space: &str, decode: Option<Object>) -> (Document, pdfcraft_cos::ObjRef, Vec<u8>) {+    let (samples, color) =+        if space == "DeviceGray" { (vec![64; 8], image::ExtendedColorType::L8) } else { ([32, 64, 96].repeat(8), image::ExtendedColorType::Rgb8) };+    let mut jpeg = Vec::new();+    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode(&samples, 4, 2, color).unwrap();+    let mut dict = image_dict(4, 2, space, 8);+    dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));+    if let Some(decode) = decode {+        dict.set(b"Decode".to_vec(), decode);+    }+    let (doc, image) = decode_fixture(dict, jpeg.clone());+    (doc, image, jpeg)+}++/// Mutate only the synthetic image dictionary, allowing indirect test objects to be added first.+fn jpeg_decode_update(doc: &mut Document, image: pdfcraft_cos::ObjRef, edit: impl FnOnce(&mut Document, &mut Dict)) {+    let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("fixture image") };+    edit(doc, &mut stream.dict);+    doc.set(image, Object::Stream(stream));+}++/// Apply the PDF component intervals to the JPEG decoder's actual samples, expressed as RGBA.+fn jpeg_decode_expected(jpeg: &[u8], pairs: &[[f64; 2]]) -> Vec<u8> {+    let decoded = image::load_from_memory(jpeg).unwrap();+    let samples = if pairs.len() == 1 { decoded.into_luma8().into_raw() } else { decoded.into_rgb8().into_raw() };+    samples+        .chunks_exact(pairs.len())+        .flat_map(|pixel| {+            let values: Vec<u8> = pixel+                .iter()+                .zip(pairs)+                .map(|(&sample, &[lo, hi])| (lo * f64::from(255 - sample) + hi * f64::from(sample)).round().clamp(0., 255.) as u8)+                .collect();+            if values.len() == 1 { [values[0], values[0], values[0], 255] } else { [values[0], values[1], values[2], 255] }+        })+        .collect()+}++/// Both public export entry points must produce the same appearance-correct PNG.+fn assert_jpeg_decode_rgba(doc: &Document, image: pdfcraft_cos::ObjRef, expected: &[u8]) {+    let out = extract_images(doc, &[0], 0);+    assert!(out.skipped.is_empty(), "{:?}", out.skipped);+    assert_eq!(out.images.len(), 1);+    assert_eq!(out.images[0].extension, "png");+    let (extension, bytes) = image_file(doc, image).unwrap();+    assert_eq!(extension, "png");+    assert_eq!(bytes, out.images[0].data);+    assert_eq!(exported_rgba(doc).1, expected);+}++/// Missing, null and exact identity maps preserve original JPEG bytes, including indirect maps.+#[test]+fn jpeg_decode_default_and_identity_preserve_exact_bytes() {+    for space in ["DeviceGray", "DeviceRGB"] {+        let n = if space == "DeviceGray" { 1 } else { 3 };+        for kind in ["missing", "null", "identity", "indirect"] {+            let (mut doc, image, jpeg) = jpeg_decode_fixture(space, None);+            jpeg_decode_update(&mut doc, image, |doc, dict| {+                let value = match kind {+                    "missing" => return,+                    "null" => Object::Null,+                    "identity" => decode_array(&[0., 1.].repeat(n)),+                    _ => {+                        let zero = doc.add(Object::Int(0));+                        let one = doc.add(Object::Int(1));+                        let map = doc.add(Object::Array((0..n).flat_map(|_| [Object::Ref(zero), Object::Ref(one)]).collect()));+                        let cs = doc.add(Object::name(space));+                        dict.set(b"ColorSpace".to_vec(), Object::Ref(cs));+                        Object::Ref(map)+                    }+                };+                dict.set(b"Decode".to_vec(), value);+            });+            let out = extract_images(&doc, &[0], 0);+            assert!(out.skipped.is_empty());+            assert_eq!(out.images.len(), 1);+            assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &jpeg), "{space}, {kind}");+            assert_eq!(image_file(&doc, image).unwrap(), ("jpg", jpeg));+        }+    }+}++/// Each Gray/RGB interval applies once, including scaled, clipped and indirect component maps.+#[test]+fn jpeg_decode_maps_device_components_and_indirect_intervals() {+    for (space, pairs) in [+        ("DeviceGray", vec![[1., 0.]]),+        ("DeviceGray", vec![[0.25, 0.75]]),+        ("DeviceGray", vec![[-0.25, 1.25]]),+        ("DeviceRGB", vec![[1., 0.]; 3]),+        ("DeviceRGB", vec![[1., 0.], [0.2, 0.8], [0.25, 0.75]]),+    ] {+        for indirect in [false, true] {+            let (mut doc, image, jpeg) = jpeg_decode_fixture(space, None);+            jpeg_decode_update(&mut doc, image, |doc, dict| {+                let endpoints: Vec<Object> = pairs.iter().flatten().map(|&v| Object::Real(v)).collect();+                let value = if indirect {+                    let endpoints = endpoints.into_iter().map(|v| Object::Ref(doc.add(v))).collect();+                    let map = doc.add(Object::Array(endpoints));+                    Object::Ref(map)+                } else {+                    Object::Array(endpoints)+                };+                dict.set(b"Decode".to_vec(), value);+                if indirect {+                    dict.set(b"DecodeParms".to_vec(), Object::Null);+                    let cs = doc.add(Object::name(if space == "DeviceGray" { "G" } else { "RGB" }));+                    dict.set(b"ColorSpace".to_vec(), Object::Ref(cs));+                }+            });+            assert_jpeg_decode_rgba(&doc, image, &jpeg_decode_expected(&jpeg, &pairs));+        }+    }+}++/// Malformed maps default as a whole even when a mask forces PNG conversion.+#[test]+fn jpeg_decode_malformed_maps_with_masks_do_not_invert_the_first_pair() {+    for space in ["DeviceGray", "DeviceRGB"] {+        let n = if space == "DeviceGray" { 1 } else { 3 };+        let mut bad = vec![Object::Null, Object::name("bad"), decode_array(&[]), decode_array(&[1.]), decode_array(&[1., 0.].repeat(n + 1))];+        for value in [Object::Null, Object::name("bad"), Object::Real(f64::NAN), Object::Real(f64::INFINITY), Object::Real(f64::NEG_INFINITY)] {+            let mut endpoints: Vec<Object> = (0..n).flat_map(|_| [Object::Int(1), Object::Int(0)]).collect();+            endpoints[2 * n - 1] = value;+            bad.push(Object::Array(endpoints));+        }+        for decode in bad {+            let (mut doc, image, jpeg) = jpeg_decode_fixture(space, None);+            jpeg_decode_update(&mut doc, image, |doc, dict| {+                let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![128; 8])));+                dict.set(b"SMask".to_vec(), Object::Ref(mask));+                dict.set(b"Decode".to_vec(), decode);+            });+            let mut expected = jpeg_decode_expected(&jpeg, &vec![[0., 1.]; n]);+            for pixel in expected.as_chunks_mut::<4>().0 {+                pixel[3] = 128;+            }+            assert_jpeg_decode_rgba(&doc, image, &expected);+        }+    }+}++/// Transparency stays independent of the color map, and color keys compare raw JPEG samples.+#[test]+fn jpeg_decode_keeps_mask_alpha_and_raw_colour_key_order() {+    for kind in ["soft", "stencil", "raw-key", "mapped-key"] {+        let (mut doc, image, jpeg) = jpeg_decode_fixture("DeviceGray", Some(decode_array(&[1., 0.])));+        let samples = image::load_from_memory(&jpeg).unwrap().into_luma8().into_raw();+        jpeg_decode_update(&mut doc, image, |doc, dict| match kind {+            "soft" => {+                let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0, 64, 128, 255, 255, 128, 64, 0])));+                dict.set(b"SMask".to_vec(), Object::Ref(mask));+            }+            "stencil" => {+                let mut mask_dict = image_dict(4, 2, "DeviceGray", 1);+                mask_dict.set(b"ImageMask".to_vec(), Object::Bool(true));+                let mask = doc.add(Object::Stream(Stream::from_raw(mask_dict, vec![0b1100_0000, 0b0011_0000])));+                dict.set(b"Mask".to_vec(), Object::Ref(mask));+            }+            _ => {+                let key = if kind == "raw-key" { samples[0] } else { 255 - samples[0] };+                dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(i64::from(key)); 2]));+            }+        });+        let alpha = match kind {+            "soft" => vec![0, 64, 128, 255, 255, 128, 64, 0],+            "stencil" => vec![0, 0, 255, 255, 255, 255, 0, 0],+            "raw-key" => vec![0; 8],+            _ => vec![255; 8],+        };+        let mut expected = jpeg_decode_expected(&jpeg, &[[1., 0.]]);+        for (pixel, alpha) in expected.as_chunks_mut::<4>().0.iter_mut().zip(alpha) {+            pixel[3] = alpha;+        }+        assert_jpeg_decode_rgba(&doc, image, &expected);+    }+}++/// Decode precedes undoing a white matte: inverted gray 64 becomes 191, then about 128 at half alpha.+#[test]+fn jpeg_decode_is_applied_before_matte_unpremultiplication() {+    let (mut doc, image, jpeg) = jpeg_decode_fixture("DeviceGray", Some(decode_array(&[1., 0.])));+    assert!(image::load_from_memory(&jpeg).unwrap().into_luma8().pixels().all(|pixel| pixel[0] == 64));+    jpeg_decode_update(&mut doc, image, |doc, dict| {+        let mut mask = image_dict(4, 2, "DeviceGray", 8);+        mask.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(1)]));+        let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![128; 8])));+        dict.set(b"SMask".to_vec(), Object::Ref(mask));+    });+    let out = extract_images(&doc, &[0], 0);+    assert!(out.skipped.is_empty());+    assert_eq!(out.images[0].extension, "png");+    assert_eq!(image_file(&doc, image).unwrap().1, out.images[0].data);+    let rgba = exported_rgba(&doc).1;+    assert!(rgba.as_chunks::<4>().0.iter().all(|pixel| pixel[..3].iter().all(|v| v.abs_diff(128) <= 1) && pixel[3] == 128), "{rgba:?}");+}++/// A required color transform must report damaged, mismatched or oversized JPEGs, never copy them.+#[test]+fn jpeg_decode_required_conversion_failures_are_reported() {+    for kind in ["corrupt", "dimensions", "oversized", "rgb-as-gray", "gray-as-rgb", "cmyk-as-rgb"] {+        let space = if kind == "gray-as-rgb" { "DeviceGray" } else { "DeviceRGB" };+        let (mut doc, image, _) = jpeg_decode_fixture(space, None);+        let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("fixture image") };+        let target = if kind == "rgb-as-gray" { "DeviceGray" } else { "DeviceRGB" };+        stream.dict.set(b"ColorSpace".to_vec(), Object::name(target));+        stream.dict.set(b"Decode".to_vec(), decode_array(&[1., 0.].repeat(if target == "DeviceGray" { 1 } else { 3 })));+        match kind {+            "corrupt" => stream.raw = b"not a decodable JPEG".to_vec().into(),+            "dimensions" => {+                stream.dict.set(b"Width".to_vec(), Object::Int(5));+            }+            "oversized" => {+                stream.dict.set(b"Width".to_vec(), Object::Int(10_001));+                stream.dict.set(b"Height".to_vec(), Object::Int(10_001));+            }+            "cmyk-as-rgb" => {+                stream.raw = flat_four_component_jpeg([0, 255, 255, 255], 0).into();+                stream.dict.set(b"Width".to_vec(), Object::Int(8));+                stream.dict.set(b"Height".to_vec(), Object::Int(8));+            }+            _ => {}+        }+        doc.set(image, Object::Stream(stream));+        let reason = image_file(&doc, image).expect_err(kind);+        assert!(!reason.is_empty(), "{kind}");+        let out = extract_images(&doc, &[0], 0);+        assert!(out.images.is_empty(), "{kind}: a wrong JPEG must not be exported");+        assert_eq!(out.skipped.len(), 1, "{kind}");+        assert_eq!(out.skipped[0].2, reason, "{kind}");+    }+}++/// Explicit DCT ColorTransform overrides cannot silently use image-rs's own color interpretation.+#[test]+fn jpeg_decode_required_conversion_refuses_explicit_color_transform() {+    for value in [Object::Int(0), Object::Int(1), Object::Null] {+        for representation in ["dict", "indirect", "array"] {+            let (mut doc, image, _) = jpeg_decode_fixture("DeviceRGB", Some(decode_array(&[1., 0.].repeat(3))));+            jpeg_decode_update(&mut doc, image, |doc, dict| {+                let transform = if representation == "dict" { value.clone() } else { Object::Ref(doc.add(value.clone())) };+                let parameters = Object::Dict(Dict::from_iter([(b"ColorTransform".to_vec(), transform)]));+                let parameters = match representation {+                    "indirect" => Object::Ref(doc.add(parameters)),+                    "array" => {+                        dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")]));+                        Object::Array(vec![parameters])+                    }+                    _ => parameters,+                };+                dict.set(b"DecodeParms".to_vec(), parameters);+            });+            let reason = image_file(&doc, image).unwrap_err();+            assert!(reason.contains("ColorTransform"), "{reason}");+            let out = extract_images(&doc, &[0], 0);+            assert!(out.images.is_empty());+            assert_eq!(out.skipped.len(), 1);+        }+    }+}++/// This device-only fix retains the existing ICC, calibrated and inverted-CMYK JPEG paths.+#[test]+fn jpeg_decode_keeps_other_colour_spaces_outside_device_mapping() {+    for kind in ["ICC", "CalRGB", "CMYK"] {+        let (mut doc, image, mut jpeg) = jpeg_decode_fixture("DeviceRGB", Some(decode_array(&[1., 0.].repeat(3))));+        jpeg_decode_update(&mut doc, image, |doc, dict| {+            let cs = match kind {+                "ICC" => {+                    let profile = doc.add(Object::Stream(Stream::from_raw(Dict::from_iter([(b"N".to_vec(), Object::Int(3))]), icc_profile(b"RGB "))));+                    Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)])+                }+                "CalRGB" => Object::Array(vec![Object::name("CalRGB"), Object::Dict(Dict::new())]),+                _ => {+                    dict.set(b"Decode".to_vec(), decode_array(&[1., 0.].repeat(4)));+                    Object::name("DeviceCMYK")+                }+            };+            dict.set(b"ColorSpace".to_vec(), cs);+        });+        if kind == "CMYK" {+            jpeg = flat_four_component_jpeg([0, 255, 255, 255], 0);+            let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("fixture image") };+            stream.raw = jpeg.clone().into();+            stream.dict.set(b"Width".to_vec(), Object::Int(8));+            stream.dict.set(b"Height".to_vec(), Object::Int(8));+            doc.set(image, Object::Stream(stream));+        }+        assert_eq!(image_file(&doc, image).unwrap(), ("jpg", jpeg.clone()), "{kind}");+        let out = extract_images(&doc, &[0], 0);+        assert!(out.skipped.is_empty());+        assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &jpeg), "{kind}");+    }+}++/// Finite opposite-sign endpoints must not overflow the interpolation or lose its sign.+#[test]+fn jpeg_decode_interpolation_avoids_finite_endpoint_overflow() {+    for (interval, expected) in [([-f64::MAX, f64::MAX], 0), ([f64::MAX, -f64::MAX], 255)] {+        let (doc, image, jpeg) = jpeg_decode_fixture("DeviceGray", Some(decode_array(&interval)));+        assert!(image::load_from_memory(&jpeg).unwrap().into_luma8().pixels().all(|p| p[0] < 128));+        assert_jpeg_decode_rgba(&doc, image, &[expected, expected, expected, 255].repeat(8));+    }+}++/// A null or empty DCT parameter dictionary has no explicit ColorTransform override.+#[test]+fn jpeg_decode_null_and_empty_parameters_still_allow_conversion() {+    for parameters in [Object::Null, Object::Dict(Dict::new()), Object::Array(vec![Object::Null])] {+        let (mut doc, image, jpeg) = jpeg_decode_fixture("DeviceGray", Some(decode_array(&[1., 0.])));+        jpeg_decode_update(&mut doc, image, |_, dict| {+            if matches!(parameters, Object::Array(_)) {+                dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")]));+            }+            dict.set(b"DecodeParms".to_vec(), parameters);+        });+        assert_jpeg_decode_rgba(&doc, image, &jpeg_decode_expected(&jpeg, &[[1., 0.]]));+    }+}++/// APP14 transform 0 also describes three-component RGB, before zune initializes components.+#[test]+fn jpeg_decode_accepts_three_component_app14_rgb() {+    use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace};+    let (mut doc, image, mut jpeg) = jpeg_decode_fixture("DeviceRGB", Some(decode_array(&[1., 0.].repeat(3))));+    let frame = jpeg.windows(2).position(|marker| marker == [0xff, 0xc0]).unwrap();+    jpeg.splice(frame..frame, [0xff, 0xee, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, 0]);+    let mut decoder = zune_jpeg::JpegDecoder::new(ZCursor::new(jpeg.as_slice()));+    decoder.decode_headers().unwrap();+    assert_eq!(decoder.info().unwrap().components, 3);+    assert_eq!(decoder.input_colorspace(), Some(ColorSpace::CMYK), "the header alone does not determine the final RGB layout");+    let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("fixture image") };+    stream.raw = jpeg.clone().into();+    doc.set(image, Object::Stream(stream));+    assert_jpeg_decode_rgba(&doc, image, &jpeg_decode_expected(&jpeg, &[[1., 0.]; 3]));+}++/// A parameter container that cannot be inspected must not hide a PDF color-transform override.+#[test]+fn jpeg_decode_refuses_unresolved_and_malformed_dct_parameters() {+    for kind in ["chain", "cycle", "slot-chain", "long-array", "number"] {+        let (mut doc, image, _) = jpeg_decode_fixture("DeviceRGB", Some(decode_array(&[1., 0.].repeat(3))));+        jpeg_decode_update(&mut doc, image, |doc, dict| {+            let parameters = doc.add(Object::Dict(Dict::from_iter([(b"ColorTransform".to_vec(), Object::Int(0))])));+            let value = match kind {+                "chain" => Object::Ref(doc.add(Object::Ref(parameters))),+                "cycle" => {+                    let cycle = doc.add(Object::Null);+                    doc.set(cycle, Object::Ref(cycle));+                    Object::Ref(cycle)+                }+                "slot-chain" => Object::Array(vec![Object::Ref(doc.add(Object::Ref(parameters)))]),+                "long-array" => Object::Array(vec![Object::Null, Object::Null]),+                _ => Object::Int(1),+            };+            dict.set(b"DecodeParms".to_vec(), value);+        });+        assert!(image_file(&doc, image).is_err(), "{kind}");+        let out = extract_images(&doc, &[0], 0);+        assert!(out.images.is_empty(), "{kind}");+        assert_eq!(out.skipped.len(), 1, "{kind}");+    }+}+ /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec<u8> {diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs--- a/crates/engine/src/copy_image.rs+++ b/crates/engine/src/copy_image.rs@@ -30,7 +30,7 @@ impl Document {     }      /// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):-    /// a JPEG as stored in the PDF, otherwise a lossless PNG.+    /// a JPEG as stored in the PDF when its appearance permits, otherwise a lossless PNG.     pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {         let editor = self.editor.as_ref().ok_or("the document can't be read")?;         let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs--- a/crates/engine/src/export.rs+++ b/crates/engine/src/export.rs@@ -42,7 +42,8 @@ impl Document { }  /// Export all images: the images `pages` (0-based) use, each once, skipping those under-/// `min_side` pixels on their shorter side. JPEGs come out unchanged, other images as PNG.+/// `min_side` pixels on their shorter side. JPEGs stay unchanged unless supported Decode,+/// transparency or CMYK polarity requires conversion; converted and other images are PNG. pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Result<pdfcraft_create::ImageExport, String> {     let doc = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), src.config.password.as_deref()).map_err(|e| e.to_string())?;     Ok(pdfcraft_create::extract_images(&doc, pages, min_side))diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml--- a/crates/ocr/Cargo.toml+++ b/crates/ocr/Cargo.toml@@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }-rten = { version = "0.26.0", default-features = false, features = ["rten_format"] }+rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true++[dev-dependencies]+tempfile = "3.27"diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs--- a/crates/ocr/src/lib.rs+++ b/crates/ocr/src/lib.rs@@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width;  /// The model files, as named in ATTRIBUTION.toml.-pub const DETECTION_MODEL: &str = "text-detection.rten";-pub const RECOGNITION_MODEL: &str = "text-recognition.rten";+pub const DETECTION_MODEL: &str = "text-detection.onnx";+pub const RECOGNITION_MODEL: &str = "text-recognition.onnx";  /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had@@ -48,10 +48,13 @@ pub struct Models { }  impl Models {-    /// The models in `dir`, if both files are there.+    /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files.+    /// An incomplete update never mixes formats or hides an intact legacy pair.     pub fn in_dir(dir: &Path) -> Option<Models> {-        let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) };-        (m.detection.is_file() && m.recognition.is_file()).then_some(m)+        [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| {+            let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) };+            (m.detection.is_file() && m.recognition.is_file()).then_some(m)+        })     }      /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them@@ -321,6 +324,63 @@ mod tests {         assert_eq!(w.up, [-10.0, 0.0]);     } +    /// The current ONNX pair wins when an installation also retains both legacy models.+    #[test]+    fn model_discovery_prefers_the_complete_onnx_pair() {+        let dir = tempfile::tempdir().unwrap();+        for file in ["text-detection.onnx", "text-recognition.onnx"] {+            std::fs::write(dir.path().join(file), b"model").unwrap();+        }+        let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") });+        assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient");+        for file in ["text-detection.rten", "text-recognition.rten"] {+            std::fs::write(dir.path().join(file), b"model").unwrap();+        }+        assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models");+    }++    /// An interrupted ONNX update still permits an intact legacy pair in the same directory.+    #[test]+    fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() {+        for current in ["text-detection.onnx", "text-recognition.onnx"] {+            let dir = tempfile::tempdir().unwrap();+            for file in [current, "text-detection.rten", "text-recognition.rten"] {+                std::fs::write(dir.path().join(file), b"model").unwrap();+            }+            assert_eq!(+                Models::in_dir(dir.path()),+                Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }),+                "{current} alone must not hide a complete legacy pair"+            );+        }+    }++    /// Detection and recognition must come from one complete format pair, never a mixed update.+    #[test]+    fn model_discovery_refuses_mixed_format_pairs() {+        for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] {+            let dir = tempfile::tempdir().unwrap();+            for file in pair {+                std::fs::write(dir.path().join(file), b"model").unwrap();+            }+            assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair");+        }+    }++    /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair.+    #[test]+    fn model_discovery_keeps_legacy_only_installations() {+        let dir = tempfile::tempdir().unwrap();+        assert_eq!(Models::in_dir(dir.path()), None);+        std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap();+        assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete");+        std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap();+        assert_eq!(+            Models::in_dir(dir.path()),+            Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") })+        );+    }+     /// #103: every release package's layout puts the models where the installed app looks.     /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}.     #[test]diff --git a/docs/releasing.md b/docs/releasing.md--- a/docs/releasing.md+++ b/docs/releasing.md@@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin deliberately. -**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call-`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to-fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text-and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where-`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip),-`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/`-(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet.+**OCR models:** desktop packaging stages the Scan & OCR models (#103).+The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256.+The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`.+Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network.+Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models.++Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair.+An incomplete ONNX update can therefore use an intact RTEN pair in the same directory.+Discovery never combines model formats or files from different directories.+Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`.+The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair.++The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD).+The web build does not include these models, and OCR remains limited to the Latin alphabet without accents.  ### macOS diff --git a/docs/roadmap.md b/docs/roadmap.md--- a/docs/roadmap.md+++ b/docs/roadmap.md@@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10, #1201 partial: DeviceGray/DeviceRGB JPEG Decode is applied during image export; 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 (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)@@ -193,7 +195,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). 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.<br>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.<br>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).<br>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. 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. |+| 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).<br>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). Named DeviceGray/DeviceRGB JPEGs with valid nonidentity Decode export as PNG with supported masks; required conversions with explicit DCT ColorTransform, corrupt data, mismatched dimensions or component counts, or excessive pixel counts are refused. Default/identity JPEGs keep their bytes when no other conversion is needed; arbitrary ICC, calibrated and CMYK JPEG Decode remains limited. 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. | | 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.<br>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<br>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<br>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. |@@ -254,6 +256,8 @@ Estimates unchanged.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M10, #1201 partial: image export maps valid nonidentity DeviceGray/DeviceRGB JPEG Decode to PNG, preserves supported masks and refuses required conversions it cannot safely perform. Default/identity JPEG paths, other color-space limits and 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-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/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1--- a/packaging/windows/test-msi.ps1+++ b/packaging/windows/test-msi.ps1@@ -288,13 +288,16 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent' # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name. if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" }-$modelFiles = 0+$modelFiles = @() $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''') try {   [void] $view.Execute()-  while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } }+  # FileName can contain a generated 8.3 name before the long name: SHORT|long.+  while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] } } finally { [void] $view.Close() }-if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" }+foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) {+  Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\"+} $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1 Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database)diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -2165,8 +2165,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"]-evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha"]-notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."+evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::jpeg_decode_maps_device_components_and_indirect_intervals", "crates/create/src/tests.rs::jpeg_decode_required_conversion_failures_are_reported", "crates/automation/tests/it/automation.rs::jpeg_decode_exports_and_image_save_follow_page_colors"]+notes = "right-click an image ▸ Save Image As: JPEG bytes unchanged when no conversion is needed, otherwise PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Valid nonidentity per-component Decode on named DeviceGray/DeviceRGB JPEGs produces PNG, including indirect color-space names, Decode arrays and endpoints, with supported masks preserved. Required Decode conversion refuses explicit DCT ColorTransform parameters, corrupt JPEG data, mismatched dimensions or component counts, and excessive pixel counts, rather than returning unchanged JPEG bytes. Default/identity JPEGs retain their original bytes when no other conversion is needed. Still limited: 16-bit samples use high bytes; arbitrary ICC/calibrated/CMYK JPEG Decode, ICC transforms and calibrated color conversion remain outside this change (#1201 partial)."  [[feature]] id = "edit.image-external-editor"@@ -6544,8 +6544,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"]-evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]-notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."+evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]+notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."  [[feature]] id = "ocr.recognize-range"@@ -7152,8 +7152,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"]-evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"]-notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported."+evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jpeg_decode_maps_device_components_and_indirect_intervals", "crates/create/src/tests.rs::jpeg_decode_required_conversion_failures_are_reported", "crates/automation/tests/it/automation.rs::jpeg_decode_exports_and_image_save_follow_page_colors"]+notes = "each image once (page resources and nested forms); JPEG unchanged when no conversion is needed, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Valid nonidentity per-component Decode on named DeviceGray/DeviceRGB JPEGs produces PNG, including indirect color-space names, Decode arrays and endpoints, with supported masks preserved. Required Decode conversion refuses explicit DCT ColorTransform parameters, corrupt JPEG data, mismatched dimensions or component counts, and excessive pixel counts, rather than returning unchanged JPEG bytes. Default/identity JPEGs retain their original bytes when no other conversion is needed. Still limited: 16-bit samples use high bytes; arbitrary ICC/calibrated/CMYK JPEG Decode, ICC transforms and calibrated color conversion remain outside this change (#1201 partial)."  [[feature]] id = "create.export-html"