Browse / github.com/storytold/pdfcraft / patch
M10: preserve BMP V5 embedded RGB profiles (#1201 partial)
M10: preserve BMP V5 embedded RGB profiles (#1201 partial) ## What this changes Addresses BMP input-profile retention in section 10 of #1201; the broader audit remains open. Create PDF and Add image discarded embedded BMP V5 profiles, so a linear-profile mid-gray sample rendered at 128 instead of the independent color-managed reference of 188. These imports now retain compatible embedded RGB profiles as `/ICCBased` through save/reopen. Neutral RGB samples keep three channels, and opacity stays in a separate grayscale soft mask. The existing BMP decoder still supplies the pixels. The metadata reader checks the V5 header, palette and raster extent be
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- KyleKassen (imported from pull request #5043, open)
- Base
e9fc890a862ea2d8c895ea32ddecda98580483cd- Size
- +594 −48 in 19 files
- Review
- pending
- Shared
- 2026-10-28
- Id
711e7d3983f10fdcfce42406
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/README.md crates/automation/tests/it/automation.rs crates/automation/tests/it/automation/bmp_icc.rs crates/create/README.md crates/create/src/lib.rs crates/create/src/tests.rs crates/create/src/tests/bmp_icc.rs crates/ocr/Cargo.toml crates/ocr/src/lib.rs docs/file-format-parity.md docs/releasing.md docs/roadmap.md packaging/windows/test-msi.ps1 parity/acrobat-features.toml
@@ -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) @@ -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"@@ -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"@@ -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).@@ -2,6 +2,7 @@ **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: BMP V5 imports retain compatible embedded RGB profiles; 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 +200,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-27 (M10, #1201 partial):** Create PDF and Add image retain compatible embedded RGB profiles from BMP V5 through save/reopen.+ Profiled neutral samples remain RGB with separate alpha; profile ranges are bounded and cannot overlap palette or pixel data.+ Linked profiles, BMP resolution, calibrated color fields and rendering-intent policy remain 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,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---|+| 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. |@@ -21,6 +21,10 @@ them with the image's current orientation. Full turns retain its appearance, rec and flips. Angles that are not multiples of 90 are refused without changing the document or its undo/redo history. +`doc_create` from images and `page_add_image` retain compatible embedded RGB profiles from BMP V5 files through save/reopen.+Profiled neutral samples remain RGB, and any alpha stays in a separate grayscale soft mask.+Linked profiles are never loaded, and malformed, incompatible, overlapping or over-limit profile data keeps the existing device-color fallback.+ `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions@@ -1,5 +1,7 @@ //! End-to-end tests of the automation tools and the MCP server, on synthetic PDFs. +mod bmp_icc;+ use std::path::{Path, PathBuf}; #[cfg(feature = "mcp")]new file mode 100644@@ -0,0 +1,126 @@+use super::*;+use pdfcraft_cos::{Document, Object};+use std::sync::Arc;++const BMP_FILE_HEADER: usize = 14;+const BMP_V5_HEADER: usize = 124;+const BMP_PIXEL_OFFSET: usize = BMP_FILE_HEADER + BMP_V5_HEADER;+const PROFILE_DATA_OFFSET: u32 = 132;+const PROFILE_SIZE: usize = 300;+const PROFILE_EMBEDDED: u32 = 0x4D42_4544;++/// A small test-only RGB ICC header, sufficient to verify that the input profile bytes survive.+fn synthetic_rgb_profile() -> Vec<u8> {+ let mut profile = vec![0u8; PROFILE_SIZE];+ profile[..4].copy_from_slice(&(PROFILE_SIZE as u32).to_be_bytes());+ profile[12..16].copy_from_slice(b"prtr");+ profile[16..20].copy_from_slice(b"RGB ");+ profile[20..24].copy_from_slice(b"XYZ ");+ profile[36..40].copy_from_slice(b"acsp");+ for (index, byte) in profile.iter_mut().enumerate().skip(128) {+ *byte = index as u8;+ }+ profile+}++/// A fixed-header 2×1 BITMAPV5HEADER BMP with two RGB pixels and an embedded profile.+fn bmp_v5_rgb(profile: &[u8]) -> Vec<u8> {+ assert_eq!(profile.len(), PROFILE_SIZE);+ let raster = [70, 80, 90, 10, 20, 30, 0, 0]; // BGR pixels and two row-padding bytes.+ let file_size = BMP_PIXEL_OFFSET + raster.len() + profile.len();+ let mut bmp = vec![0u8; BMP_PIXEL_OFFSET];++ bmp[..2].copy_from_slice(b"BM");+ bmp[2..6].copy_from_slice(&(file_size as u32).to_le_bytes());+ bmp[10..14].copy_from_slice(&(BMP_PIXEL_OFFSET as u32).to_le_bytes());++ let dib = BMP_FILE_HEADER;+ bmp[dib..dib + 4].copy_from_slice(&(BMP_V5_HEADER as u32).to_le_bytes());+ bmp[dib + 4..dib + 8].copy_from_slice(&2i32.to_le_bytes());+ bmp[dib + 8..dib + 12].copy_from_slice(&1i32.to_le_bytes());+ bmp[dib + 12..dib + 14].copy_from_slice(&1u16.to_le_bytes());+ bmp[dib + 14..dib + 16].copy_from_slice(&24u16.to_le_bytes());+ bmp[dib + 16..dib + 20].copy_from_slice(&0u32.to_le_bytes()); // BI_RGB.+ bmp[dib + 20..dib + 24].copy_from_slice(&(raster.len() as u32).to_le_bytes());+ bmp[dib + 56..dib + 60].copy_from_slice(&PROFILE_EMBEDDED.to_le_bytes());+ bmp[dib + 112..dib + 116].copy_from_slice(&PROFILE_DATA_OFFSET.to_le_bytes());+ bmp[dib + 116..dib + 120].copy_from_slice(&(PROFILE_SIZE as u32).to_le_bytes());++ bmp.extend_from_slice(&raster);+ bmp.extend_from_slice(profile);+ assert_eq!(bmp.len(), file_size);+ bmp+}++/// Return the first image's ICC profile, component samples and dimensions from a saved PDF.+fn saved_image(pdf: &[u8]) -> (u32, u32, Vec<u8>, i64, Vec<u8>, Vec<u8>) {+ let doc = Document::open(Arc::new(pdf.to_vec())).unwrap();+ let root = doc.root().unwrap();+ let pages = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap();+ let page_tree = doc.get(pages);+ let kids = page_tree.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();+ let page = doc.resolve(kids.first().unwrap());+ let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());+ let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());+ let (width, height, samples, image_dict) = xobjects+ .as_dict()+ .unwrap()+ .iter()+ .find_map(|(_, value)| {+ let object = doc.resolve(value);+ match &*object {+ Object::Stream(stream) if stream.dict.name(b"Subtype") == Some(&b"Image"[..]) => Some((+ stream.dict.int(b"Width").unwrap() as u32,+ stream.dict.int(b"Height").unwrap() as u32,+ stream.decoded().unwrap(),+ stream.dict.clone(),+ )),+ _ => None,+ }+ })+ .expect("saved page has an image XObject");+ let color_space = image_dict.get(b"ColorSpace").unwrap().as_array().unwrap();+ assert_eq!(color_space.first().and_then(Object::as_name), Some(&b"ICCBased"[..]));+ let profile = doc.resolve(color_space.get(1).unwrap());+ let Object::Stream(profile) = &*profile else { panic!("ICCBased profile stream") };+ (width, height, samples, profile.dict.int(b"N").unwrap(), profile.dict.name(b"Alternate").unwrap().to_vec(), profile.decoded().unwrap())+}++fn rendered_page(a: &mut Automation, doc: u64) -> (u32, u32) {+ let content = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();+ let Content::Png { width, height, .. } = content.first().unwrap() else { panic!("rendered page PNG") };+ (*width, *height)+}++#[test]+fn bmp_v5_icc_survives_create_add_save_reopen_and_preserves_source() {+ let profile = synthetic_rgb_profile();+ let source = bmp_v5_rgb(&profile);+ let dir = workdir("bmp-v5-icc");+ std::fs::create_dir_all(dir.join("src")).unwrap();+ std::fs::write(dir.join("src/profiled.bmp"), &source).unwrap();+ let expected_samples = [90, 80, 70, 30, 20, 10];+ let mut a = auto(&dir);++ let created = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/profiled.bmp"] }))["doc"].as_u64().unwrap();+ ok(&mut a, "doc_save", json!({ "doc": created, "path": "created.pdf" }));+ ok(&mut a, "doc_close", json!({ "doc": created }));+ let created_reopened = ok(&mut a, "doc_open", json!({ "path": "created.pdf" }))["doc"].as_u64().unwrap();+ assert_eq!(rendered_page(&mut a, created_reopened), (2, 1), "Create output renders after save and reopen");+ assert_eq!(+ saved_image(&std::fs::read(dir.join("created.pdf")).unwrap()),+ (2, 1, expected_samples.to_vec(), 3, b"DeviceRGB".to_vec(), profile.clone())+ );+ ok(&mut a, "doc_close", json!({ "doc": created_reopened }));++ let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 64, "height": 32 }))["doc"].as_u64().unwrap();+ ok(&mut a, "page_add_image", json!({ "doc": blank, "page": 1, "path": "src/profiled.bmp", "rect": [0, 0, 2, 1] }));+ ok(&mut a, "doc_save", json!({ "doc": blank, "path": "added.pdf" }));+ ok(&mut a, "doc_close", json!({ "doc": blank }));+ let added_reopened = ok(&mut a, "doc_open", json!({ "path": "added.pdf" }))["doc"].as_u64().unwrap();+ assert_eq!(rendered_page(&mut a, added_reopened), (64, 32), "Add Image output renders after save and reopen");+ assert_eq!(saved_image(&std::fs::read(dir.join("added.pdf")).unwrap()), (2, 1, expected_samples.to_vec(), 3, b"DeviceRGB".to_vec(), profile));++ assert_eq!(std::fs::read(dir.join("src/profiled.bmp")).unwrap(), source, "both import workflows leave the source unchanged");+ let _ = std::fs::remove_dir_all(dir);+}@@ -7,11 +7,15 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: (PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG- `iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with+ `iCCP`, BMP V5 embedded RGB profile) that matches the image's colour space is kept as an `/ICCBased` colour space with the device space as `/Alternate`; images with the same profile share one profile object. JPEG metadata scanning skips marker fill bytes and reads application segments after the frame header, stopping at the first scan or end marker. Broken or mismatched ICC profiles- still fall back to the device colour space;+ still fall back to the device colour space.+ BMP V5 profiles must follow the bitmap bits, fit in the source and be at most 16 MiB.+ Palette or pixel overlap, malformed metadata and incompatible profiles keep the device colour fallback.+ Profiled neutral BMPs retain RGB samples, and alpha stays in a separate grayscale soft mask.+ Linked profiles are never loaded; BMP resolution, calibrated color fields and rendering-intent policy are unchanged; - `from_images_with_resolution(images, ImageResolution::Dpi(dpi))`: override the page resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi for one point per pixel, or `ImageResolution::Embedded` for the same behavior as@@ -366,8 +366,13 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> { /// 8-bit RGBA pixels → an image (gray when every pixel is, with a soft mask when any pixel is /// not opaque).-fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded {- let gray = rgba.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]);+fn rgba_image(rgba: &[u8], px: (u32, u32), dpi: (f64, f64)) -> Embedded {+ rgba_image_in_space(rgba, px, dpi, false)+}++/// An RGB profile describes RGB samples even when all three channels happen to be equal.+fn rgba_image_in_space(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64), preserve_rgb: bool) -> Embedded {+ let gray = !preserve_rgb && rgba.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]); let opaque = rgba.as_chunks::<4>().0.iter().all(|p| p[3] == 255); let colour: Vec<u8> = if gray { rgba.as_chunks::<4>().0.iter().map(|p| p[0]).collect()@@ -390,6 +395,55 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded { Embedded { dict: d, data: colour, filtered: false, smask, icc: None, px: (w, h), dpi } } +/// Read only a BMP V5's embedded RGB profile; linked profiles never cause file or network I/O.+fn bmp_icc(bytes: &[u8], (w, h): (u32, u32)) -> Option<Vec<u8>> {+ const MAX_BMP_ICC_BYTES: usize = 16 << 20;+ let word = |at: usize| -> Option<u32> { Some(u32::from_le_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?)) };+ // BITMAPFILEHEADER is 14 bytes; these fields are relative to its following V5 header.+ if !bytes.starts_with(b"BM") || word(14)? != 124 || word(70)? != 0x4D42_4544 {+ return None;+ }+ let offset = usize::try_from(word(126)?).ok()?;+ let len = usize::try_from(word(130)?).ok()?;+ if offset < 124 || !(128..=MAX_BMP_ICC_BYTES).contains(&len) {+ return None;+ }+ let start = 14usize.checked_add(offset)?;+ let bits = usize::try_from(word(28)? & 0xffff).ok()?;+ if !matches!(bits, 1 | 4 | 8 | 16 | 24 | 32) {+ return None;+ }+ let used = usize::try_from(word(46)?).ok()?;+ let colors = if used == 0 && bits <= 8 { 1usize << bits } else { used };+ if bits <= 8 && colors > 1usize << bits {+ return None;+ }+ let palette_end = (14usize + 124).checked_add(colors.checked_mul(4)?)?;+ let raster_start = usize::try_from(word(10)?).ok()?;+ // V5 masks are inside the header. Uncompressed rows are DWORD-aligned; RLE's+ // positive SizeImage supplies the compressed span (BITMAPV5HEADER, Microsoft).+ let raster_bytes = match (word(30)?, bits) {+ (0 | 3, _) => {+ let row_words = usize::try_from(w).ok()?.checked_mul(bits)?.checked_add(31)? / 32;+ row_words.checked_mul(4)?.checked_mul(usize::try_from(h).ok()?)?+ }+ (1, 8) | (2, 4) => usize::try_from(word(34)?).ok()?,+ _ => return None,+ };+ let raster_end = raster_start.checked_add(raster_bytes)?;+ // In a BMP file the profile follows the bitmap bits, never the header,+ // color table or raster. Establish the entire source range before copying.+ if raster_bytes == 0 || raster_start < palette_end || start < raster_end {+ return None;+ }+ bytes.get(raster_start..raster_end)?;+ let data = bytes.get(start..start.checked_add(len)?)?;+ let mut profile = Vec::new();+ profile.try_reserve_exact(len).ok()?;+ profile.extend_from_slice(data);+ icc_for(profile, 3)+}+ /// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read: /// 72 dpi, as for images without one. fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embedded, CreateError> {@@ -403,7 +457,10 @@ fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embed } let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?; let rgba = img.into_rgba8();- Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))+ let icc = if format == image::ImageFormat::Bmp { bmp_icc(bytes, rgba.dimensions()) } else { None };+ let mut embedded = rgba_image_in_space(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0), icc.is_some());+ embedded.icc = icc;+ Ok(embedded) } /// Largest GIF logical screen, in pixels (4096×4096).@@ -4,6 +4,8 @@ use pdfcraft_cos::{SaveOptions, write_full}; use super::*; +mod bmp_icc;+ fn reopen(doc: &Document) -> Document { let bytes = write_full(doc, &SaveOptions::default()).unwrap(); hayro_syntax::Pdf::new(bytes.clone()).expect("parses");new file mode 100644@@ -0,0 +1,260 @@+use super::*;+use image::ImageEncoder;++const LCS_SRGB: u32 = 0x7352_4742;+const PROFILE_LINKED: u32 = 0x4C49_4E4B;+const PROFILE_EMBEDDED: u32 = 0x4D42_4544;+const MAX_BMP_ICC_BYTES: usize = 16 << 20;++fn bmp_bytes(width: u32, height: u32, color: image::ExtendedColorType, pixels: &[u8]) -> Vec<u8> {+ let mut bytes = Vec::new();+ image::codecs::bmp::BmpEncoder::new(&mut bytes).write_image(pixels, width, height, color).unwrap();+ bytes+}++fn le_u32(bytes: &[u8], at: usize) -> u32 {+ u32::from_le_bytes(bytes.get(at..at + 4).unwrap().try_into().unwrap())+}++/// Upgrade a source-generated BMP to BITMAPV5HEADER and optionally append the profile payload.+/// `profile_data` is relative to the start of the DIB header, and the profile follows the pixels.+fn bmp_v5(base: &[u8], color_space: u32, profile_payload: &[u8], profile_data: Option<u32>, profile_size: Option<u32>) -> Vec<u8> {+ assert!(base.starts_with(b"BM"));+ let old_offset = usize::try_from(le_u32(base, 10)).unwrap();+ let old_header_size = usize::try_from(le_u32(base, 14)).unwrap();+ assert!(old_header_size <= 124);+ let old_header_end = 14 + old_header_size;+ let table = base.get(old_header_end..old_offset).unwrap();+ let pixels = base.get(old_offset..).unwrap();++ let mut header = base.get(14..old_header_end).unwrap().to_vec();+ header.resize(124, 0);+ header[..4].copy_from_slice(&124u32.to_le_bytes());+ header[56..60].copy_from_slice(&color_space.to_le_bytes());+ let default_profile_data = u32::try_from(124 + table.len() + pixels.len()).unwrap();+ let profile_data = profile_data.unwrap_or(if profile_payload.is_empty() { 0 } else { default_profile_data });+ header[112..116].copy_from_slice(&profile_data.to_le_bytes());+ header[116..120].copy_from_slice(&profile_size.unwrap_or(profile_payload.len() as u32).to_le_bytes());++ let new_pixel_offset = 14 + header.len() + table.len();+ let mut out = base.get(..14).unwrap().to_vec();+ out.extend_from_slice(&header);+ out.extend_from_slice(table);+ out.extend_from_slice(pixels);+ out.extend_from_slice(profile_payload);+ let file_size = u32::try_from(out.len()).unwrap();+ out[2..6].copy_from_slice(&file_size.to_le_bytes());+ out[10..14].copy_from_slice(&u32::try_from(new_pixel_offset).unwrap().to_le_bytes());+ out+}++type ImageParts = (Dict, Vec<u8>, Option<(Dict, Vec<u8>)>);++fn image_parts(doc: &Document) -> ImageParts {+ let page = pages(doc).remove(0);+ let resources = doc.resolve(page.get(b"Resources").unwrap());+ let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());+ let image = doc.resolve(xobjects.as_dict().unwrap().get(b"Im0").unwrap());+ let Object::Stream(image) = &*image else { panic!("image XObject") };+ let mask = image.dict.get(b"SMask").map(|reference| {+ let mask = doc.resolve(reference);+ let Object::Stream(mask) = &*mask else { panic!("soft mask XObject") };+ (mask.dict.clone(), mask.decoded().unwrap())+ });+ (image.dict.clone(), image.decoded().unwrap(), mask)+}++#[test]+fn bmp_v5_embedded_rgb_profile_survives_create_save_reopen_and_image_xobject() {+ let pixels = [128, 128, 128, 128, 64, 32];+ let profile = icc_profile(b"RGB ");+ let base = bmp_bytes(2, 1, image::ExtendedColorType::Rgb8, &pixels);+ let bytes = bmp_v5(&base, PROFILE_EMBEDDED, &profile, None, None);++ let embedded = embed("profiled.bmp", &bytes, 1).unwrap().remove(0);+ assert_eq!(embedded.icc, Some(profile.clone()));+ assert_eq!(embedded.dict.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));+ assert_eq!(embedded.data, pixels);+ assert_eq!(embedded.px, (2, 1));+ assert_eq!(embedded.dpi, (72.0, 72.0), "BMP resolution remains at the existing fallback");++ let doc = reopen(&from_images(&[("profiled.bmp".into(), bytes.clone())]).unwrap());+ assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 1.0]);+ let (image, samples, mask) = image_parts(&doc);+ assert!(mask.is_none());+ assert_eq!(samples, pixels);+ let (n, alternate, actual, _) = icc_of(&doc, &image).expect("BMP V5 profile survives save/reopen");+ assert_eq!((n, alternate.as_slice(), actual), (3, &b"DeviceRGB"[..], profile.clone()));++ let mut doc = Document::new_empty();+ let (reference, size) = image_xobject(&mut doc, "profiled.bmp", &bytes).unwrap();+ assert_eq!(size, (2.0, 1.0));+ let Object::Stream(image) = &*doc.get(reference) else { panic!("image XObject") };+ let (n, alternate, actual, _) = icc_of(&doc, &image.dict).expect("image_xobject keeps the BMP profile");+ assert_eq!((n, alternate.as_slice(), actual), (3, &b"DeviceRGB"[..], profile));+ assert_eq!(image.decoded().unwrap(), pixels);+}++#[test]+fn bmp_v5_bitfields_alpha_keeps_rgb_profile_on_color_and_soft_mask_separate() {+ let rgba = [70, 70, 70, 128, 90, 80, 70, 255];+ let profile = icc_profile(b"RGB ");+ let base = bmp_bytes(2, 1, image::ExtendedColorType::Rgba8, &rgba);+ assert_eq!(le_u32(&base, 14), 108, "the source encoder writes a V4 BITFIELDS header");+ let bytes = bmp_v5(&base, PROFILE_EMBEDDED, &profile, None, None);++ let doc = reopen(&from_images(&[("alpha.bmp".into(), bytes)]).unwrap());+ let (image, samples, mask) = image_parts(&doc);+ assert_eq!(samples, [70, 70, 70, 90, 80, 70]);+ let (n, alternate, actual, _) = icc_of(&doc, &image).unwrap();+ assert_eq!((n, alternate.as_slice(), actual), (3, &b"DeviceRGB"[..], profile));+ let (mask, alpha) = mask.expect("BMP alpha is retained as a soft mask");+ assert_eq!(mask.name(b"ColorSpace"), Some(&b"DeviceGray"[..]));+ assert_eq!(alpha, [128, 255]);+ assert!(!mask.contains(b"ICCBased"));+}++#[test]+fn bmp_v5_indexed_neutral_palette_retains_rgb_profile_and_rgb_samples() {+ let palette_indices = [32, 128, 224];+ let profile = icc_profile(b"RGB ");+ let base = bmp_bytes(3, 1, image::ExtendedColorType::L8, &palette_indices);+ let bytes = bmp_v5(&base, PROFILE_EMBEDDED, &profile, None, None);++ let doc = reopen(&from_images(&[("indexed.bmp".into(), bytes)]).unwrap());+ let (image, samples, mask) = image_parts(&doc);+ assert!(mask.is_none());+ assert_eq!(samples, [32, 32, 32, 128, 128, 128, 224, 224, 224]);+ let (n, alternate, actual, _) = icc_of(&doc, &image).expect("indexed BMP's RGB profile is retained");+ assert_eq!((n, alternate.as_slice(), actual), (3, &b"DeviceRGB"[..], profile));+}++#[test]+fn bmp_v4_and_bmp_v5_without_embedded_profile_keep_device_color() {+ let pixels = [90, 80, 70, 30, 20, 10];+ let base = bmp_bytes(2, 1, image::ExtendedColorType::Rgb8, &pixels);+ let v4_rgba = bmp_bytes(2, 1, image::ExtendedColorType::Rgba8, &[90, 80, 70, 255, 30, 20, 10, 255]);+ assert_eq!(le_u32(&v4_rgba, 14), 108);+ let without_tag = bmp_v5(&base, LCS_SRGB, &[], None, Some(0));++ for bytes in [v4_rgba, without_tag] {+ let embedded = embed("untagged.bmp", &bytes, 1).unwrap().remove(0);+ assert!(embedded.icc.is_none());+ assert_eq!(embedded.dict.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));+ assert_eq!(embedded.data.len(), 6);+ }+}++#[test]+fn bmp_v5_bad_or_unsupported_profiles_fall_back_without_losing_pixels() {+ let pixels = [90, 80, 70, 30, 20, 10];+ let base = bmp_bytes(2, 1, image::ExtendedColorType::Rgb8, &pixels);+ let good = icc_profile(b"RGB ");+ let mut bad_signature = good.clone();+ bad_signature[36] = b'x';++ let linked_path = b"profile.icc\0";+ let truncated_profile = &good[..64];+ let oversized = (MAX_BMP_ICC_BYTES + 1) as u32;+ let cases = [+ ("missing", bmp_v5(&base, LCS_SRGB, &[], None, Some(0))),+ ("linked profile", bmp_v5(&base, PROFILE_LINKED, linked_path, None, None)),+ ("wrong component space", bmp_v5(&base, PROFILE_EMBEDDED, &icc_profile(b"GRAY"), None, None)),+ ("short header", bmp_v5(&base, PROFILE_EMBEDDED, &[0; 127], None, None)),+ ("bad signature", bmp_v5(&base, PROFILE_EMBEDDED, &bad_signature, None, None)),+ ("zero profile size", bmp_v5(&base, PROFILE_EMBEDDED, &good, None, Some(0))),+ ("zero profile offset", bmp_v5(&base, PROFILE_EMBEDDED, &good, Some(0), None)),+ ("out-of-range profile offset", bmp_v5(&base, PROFILE_EMBEDDED, &good, Some(u32::MAX - 8), None)),+ ("truncated profile data", bmp_v5(&base, PROFILE_EMBEDDED, truncated_profile, None, Some(good.len() as u32))),+ ("over-limit declared profile size", bmp_v5(&base, PROFILE_EMBEDDED, &good, None, Some(oversized))),+ ];++ for (label, bytes) in cases {+ let embedded = embed("fallback.bmp", &bytes, 1).unwrap().remove(0);+ assert!(embedded.icc.is_none(), "{label}");+ assert_eq!(embedded.dict.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]), "{label}");+ assert_eq!(embedded.data, pixels, "{label}");+ let doc = reopen(&from_images(&[("fallback.bmp".into(), bytes)]).unwrap());+ assert_eq!(image_of(&doc, 0).name(b"ColorSpace"), Some(&b"DeviceRGB"[..]), "{label}");+ }+}++#[test]+fn bmp_v5_profile_range_inside_pixel_payload_is_ignored() {+ let profile = icc_profile(b"RGB ");+ let disk_pixels = profile.as_chunks::<3>().0.iter().flat_map(|bgr| [bgr[2], bgr[1], bgr[0]]).collect::<Vec<_>>();+ let base = bmp_bytes(100, 1, image::ExtendedColorType::Rgb8, &disk_pixels);+ assert_eq!(le_u32(&base, 14), 40);++ // The pixel bytes themselves form a plausible ICC profile, but are not profile storage.+ let overlapped = bmp_v5(&base, PROFILE_EMBEDDED, &[], Some(124), Some(profile.len() as u32));+ let control = bmp_v5(&base, LCS_SRGB, &[], Some(0), Some(0));++ let overlapped = embed("overlap.bmp", &overlapped, 1).unwrap().remove(0);+ let control = embed("control.bmp", &control, 1).unwrap().remove(0);+ assert!(overlapped.icc.is_none());+ assert_eq!(overlapped.dict.name(b"ColorSpace"), control.dict.name(b"ColorSpace"));+ assert_eq!(overlapped.data, control.data);+ assert_eq!(overlapped.data, disk_pixels);+}++#[test]+fn bmp_v5_profile_range_inside_unused_palette_entries_is_ignored() {+ let indices = [200, 201, 202];+ let base = bmp_bytes(3, 1, image::ExtendedColorType::L8, &indices);+ let pixel_offset = usize::try_from(le_u32(&base, 10)).unwrap();+ let palette_start = 14 + usize::try_from(le_u32(&base, 14)).unwrap();+ assert!(pixel_offset - palette_start >= 300, "the indexed BMP has a full palette");++ let profile = icc_profile(b"RGB ");+ let mut palette_overlap = base.clone();+ palette_overlap[palette_start..palette_start + profile.len()].copy_from_slice(&profile);+ let overlapped = bmp_v5(&palette_overlap, PROFILE_EMBEDDED, &[], Some(124), Some(profile.len() as u32));+ let control = bmp_v5(&palette_overlap, LCS_SRGB, &[], Some(0), Some(0));++ let overlapped = embed("palette-overlap.bmp", &overlapped, 1).unwrap().remove(0);+ let control = embed("palette-control.bmp", &control, 1).unwrap().remove(0);+ assert!(overlapped.icc.is_none());+ assert_eq!(overlapped.dict.name(b"ColorSpace"), control.dict.name(b"ColorSpace"));+ assert_eq!(overlapped.data, control.data);+}++#[test]+fn bmp_v5_rle8_embedded_profile_after_compressed_pixels_is_retained() {+ let indices = [32, 128, 224];+ let base = bmp_bytes(3, 1, image::ExtendedColorType::L8, &indices);+ let mut bytes = bmp_v5(&base, PROFILE_EMBEDDED, &icc_profile(b"RGB "), None, None);+ let pixel_offset = usize::try_from(le_u32(&bytes, 10)).unwrap();+ let profile_offset = 14 + usize::try_from(le_u32(&bytes, 126)).unwrap();+ let compressed = [0, 3, 32, 128, 224, 0, 0, 1];+ let old_pixel_end = profile_offset;+ assert!(pixel_offset <= old_pixel_end);+ bytes.splice(pixel_offset..old_pixel_end, compressed);++ bytes[30..34].copy_from_slice(&1u32.to_le_bytes()); // BI_RLE8+ bytes[34..38].copy_from_slice(&(compressed.len() as u32).to_le_bytes());+ let table_len = pixel_offset - (14 + 124);+ let new_profile_data = 124 + table_len + compressed.len();+ bytes[126..130].copy_from_slice(&(new_profile_data as u32).to_le_bytes());+ let file_size = u32::try_from(bytes.len()).unwrap();+ bytes[2..6].copy_from_slice(&file_size.to_le_bytes());++ let embedded = embed("rle8.bmp", &bytes, 1).unwrap().remove(0);+ assert_eq!(embedded.icc, Some(icc_profile(b"RGB ")));+ assert_eq!(embedded.data, [32, 32, 32, 128, 128, 128, 224, 224, 224]);+}++#[test]+fn bmp_v5_fully_present_profile_above_byte_cap_is_ignored() {+ let pixels = [90, 80, 70, 30, 20, 10];+ let base = bmp_bytes(2, 1, image::ExtendedColorType::Rgb8, &pixels);+ let mut profile = icc_profile(b"RGB ");+ profile.resize(MAX_BMP_ICC_BYTES + 1, 0);+ let size = u32::try_from(profile.len()).unwrap();+ profile[..4].copy_from_slice(&size.to_be_bytes());+ let bytes = bmp_v5(&base, PROFILE_EMBEDDED, &profile, None, None);+ let embedded = embed("over-cap.bmp", &bytes, 1).unwrap().remove(0);+ assert!(embedded.icc.is_none(), "a complete profile must still obey the metadata cap");+ assert_eq!(embedded.data, pixels);+ assert_eq!(embedded.dict.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));+}@@ -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"@@ -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]@@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** patch (`.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)+> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (BMP V5 embedded RGB ICC retention qualified) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation@@ -63,7 +63,7 @@ features total). | Source | Acrobat | PdfCraft | Fidelity | Tests and evidence | |---|---|---|---|---|-| JPEG, PNG, TIFF (multipage, and BigTIFF), GIF, BMP, JPEG 2000 | yes | yes (ICC profiles kept; DPI choices) | good | `create.from-images`, `create.from-multipage-tiff`, `bigtiff_images_become_pages` |+| JPEG, PNG, TIFF (multipage, and BigTIFF), GIF, BMP, JPEG 2000 | yes | yes (compatible JPEG/PNG ICC and BMP V5 embedded RGB profiles kept; DPI choices) | good | `create.from-images`, `create.from-multipage-tiff`, `bigtiff_images_become_pages` | | PCX | yes | no | none | in Acrobat's document types | | Plain text | yes | yes | good | `create.from-text` | | Clipboard (image or text) | yes | yes | good | `create.from-clipboard` |@@ -110,6 +110,7 @@ features total). | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | BMP profile retention is limited to compatible embedded RGB profiles in V5 headers; linked profiles and existing resolution/color-policy limits remain. | | 2026-10-19 | patch | Certificates row: `.p7b`/`.p7c` bundles (DER or PEM `PKCS7`/`CMS`) are read for trust import. | | 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. | | 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. |@@ -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 @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10, #1201 partial: BMP V5 imports retain compatible embedded RGB profiles; 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 +194,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). 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. BMP V5 imports retain compatible embedded RGB ICC profiles through create/add/save/reopen, with separate alpha and bounded non-overlapping metadata; existing resolution and color-policy limits remain (#1201 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). 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 +255,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---|+| 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. |@@ -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)@@ -2082,8 +2082,8 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["page_add_image"]-evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"]-notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only)"+evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/create/src/tests/bmp_icc.rs::bmp_v5_embedded_rgb_profile_survives_create_save_reopen_and_image_xobject", "crates/create/src/tests/bmp_icc.rs::bmp_v5_bitfields_alpha_keeps_rgb_profile_on_color_and_soft_mask_separate", "crates/create/src/tests/bmp_icc.rs::bmp_v5_profile_range_inside_pixel_payload_is_ignored", "crates/create/src/tests/bmp_icc.rs::bmp_v5_profile_range_inside_unused_palette_entries_is_ignored", "crates/create/src/tests/bmp_icc.rs::bmp_v5_fully_present_profile_above_byte_cap_is_ignored", "crates/automation/tests/it/automation/bmp_icc.rs::bmp_v5_icc_survives_create_add_save_reopen_and_preserves_source"]+notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only). BMP V5 embedded RGB profiles remain ICCBased through save/reopen, including neutral pixels and separate grayscale alpha. Profile data is capped at 16 MiB and must fit after the bitmap bits without palette/pixel overlap. Linked profiles, calibrated color fields, resolution and rendering-intent policy are unchanged" [[feature]] id = "edit.cut-copy-paste-objects"@@ -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"@@ -6872,8 +6872,8 @@ milestone = "M10" status = "shipped" commands = ["create.images"] tools = ["doc_create"]-evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space"]-notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet"+evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::embedded_icc_profiles_tag_the_image_colour_space", "crates/create/src/tests/bmp_icc.rs::bmp_v5_embedded_rgb_profile_survives_create_save_reopen_and_image_xobject", "crates/create/src/tests/bmp_icc.rs::bmp_v5_bitfields_alpha_keeps_rgb_profile_on_color_and_soft_mask_separate", "crates/create/src/tests/bmp_icc.rs::bmp_v5_profile_range_inside_pixel_payload_is_ignored", "crates/create/src/tests/bmp_icc.rs::bmp_v5_profile_range_inside_unused_palette_entries_is_ignored", "crates/create/src/tests/bmp_icc.rs::bmp_v5_fully_present_profile_above_byte_cap_is_ignored", "crates/automation/tests/it/automation/bmp_icc.rs::bmp_v5_icc_survives_create_add_save_reopen_and_preserves_source"]+notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution). JPEG and PNG ICC profiles are kept as /ICCBased; TIFF profiles are not read yet. BMP V5 embedded RGB profiles remain ICCBased through save/reopen, including neutral pixels and separate grayscale alpha. Profile data is capped at 16 MiB and must fit after the bitmap bits without palette/pixel overlap. Linked profiles, calibrated color fields, resolution and rendering-intent policy are unchanged" [[feature]] id = "create.from-multipage-tiff"