diff --git a/ROADMAP.md b/ROADMAP.md index eb5018a097..ecc10529bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. @@ -496,6 +497,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/crates/create/README.md b/crates/create/README.md index 88c6da2ea5..273b574df5 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. + +Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. +Malformed explicit Decode arrays return an export error. Missing or null arrays keep the default mapping. +The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +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 index 1034489458..e431e437e4 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), 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) }; - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. - let inverted = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); + // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let inverted = (jpeg || mask) + && s.dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. @@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), 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)? }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples + // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { + let decoded = |i: usize| { + decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { + let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); + // Avoid overflowing hi - lo for finite endpoints with opposite signs. + (1.0 - t) * lo + t * hi + }) + }; let v = |i: usize| { + if let Some(value) = decoded(i) { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; @@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), Space::Indexed(base, hival, table) => { - // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). - let i = px.first().copied().unwrap_or(0).min(*hival) as usize; + // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). + // Without Decode, retain raw indices rather than normalizing to hival. + let i = match decoded(0) { + Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, + None => px.first().copied().unwrap_or(0).min(*hival) as usize, + }; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -198,6 +214,27 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), 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). +fn decode(doc: &Document, s: &Stream, n: usize) -> Result>, String> { + let Some(value) = s.dict.get(b"Decode") else { return Ok(None) }; + let value = doc.resolve(value); + if matches!(&*value, Object::Null) { + return Ok(None); + } + let values = value.as_array().ok_or("the image Decode must be an array")?; + if values.len() != n * 2 { + return Err(format!("the image Decode must contain {} numbers", n * 2)); + } + let endpoint = |value: &Object| { + doc.resolve(value).as_f64().filter(|v| v.is_finite()).ok_or_else(|| "the image Decode must contain finite numbers".to_string()) + }; + let mut pairs = Vec::with_capacity(n); + for [lo, hi] in values.as_chunks::<2>().0 { + pairs.push([endpoint(lo)?, endpoint(hi)?]); + } + Ok(Some(pairs)) +} + /// 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()?); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 476316f346..978dfde83f 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -887,6 +887,194 @@ fn indexed_hival_keeps_existing_short_palette_fallback() { assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); } +/// Save and reopen a synthetic image before checking both export entry points. +fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::from_raw(dict, samples)); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap(); + (doc, image) +} + +fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) { + let (doc, image) = decode_fixture(dict, samples); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (ext, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +fn decode_array(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +#[test] +fn lossless_decode_maps_each_component_before_colour_conversion() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]), + ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]), + ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]), + ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]), + ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode_array(&decode)); + assert_decode_pixels(dict, samples, &expected); + } +} + +#[test] +fn lossless_decode_scales_packed_samples_by_their_bit_depth() { + let mut dict = image_dict(4, 1, "DeviceGray", 2); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]); +} + +#[test] +fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() { + for (bpc, samples, expected) in [ + (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]), + (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]), + (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]), + (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]), + (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]), + ] { + for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] { + let mut dict = image_dict(1, 2, "DeviceGray", bpc); + if let Some(decode) = decode { + dict.set(b"Decode".to_vec(), decode); + } + assert_decode_pixels(dict, samples.clone(), &expected); + } + } +} + +#[test] +fn lossless_indexed_decode_rounds_in_index_units_then_clamps() { + let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + for (bpc, samples, decode, expected) in [ + (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]), + (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]), + (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]), + (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]), + // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5. + (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]), + ] { + let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples); + if let Some(decode) = decode { + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&decode)); + doc.set(image, Object::Stream(stream)); + } + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() { + let mut dict = image_dict(2, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)])); + assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]); + + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.])); + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255]; + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_resolves_indirect_arrays_and_endpoints() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let lo = doc.add(Object::Real(0.2)); + let hi = doc.add(Object::Real(0.8)); + let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)])); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![0]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let decode = doc.add(Object::Null); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![64]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); +} + +#[test] +fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { + let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX])); + doc.set(image, Object::Stream(stream)); + let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255]; + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_rejects_malformed_explicit_values() { + for decode in [ + Object::name("bad"), + decode_array(&[]), + decode_array(&[0.]), + decode_array(&[0., 1., 0., 1.]), + Object::Array(vec![Object::Int(0), Object::name("bad")]), + Object::Array(vec![Object::Int(0), Object::Null]), + ] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode); + let (doc, image) = decode_fixture(dict, vec![0]); + assert!(image_file(&doc, image).unwrap_err().contains("Decode")); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1); + assert!(out.skipped[0].2.contains("Decode")); + } + // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![0]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0]))); + assert!(image_file(&doc, image).unwrap_err().contains("Decode")); + assert_eq!(extract_images(&doc, &[0], 0).skipped.len(), 1); + } +} + /// 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 { diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..d6dc3f023c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -126,7 +126,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.
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. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | 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). 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.
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). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1d49b99b6f..3288ecbf27 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2130,8 +2130,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/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG" +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/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"] +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. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -7076,8 +7076,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/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"] -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" +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/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/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. Malformed Decode arrays return an error. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html"