Merge PR #2793: M10: preserve component Decode in lossless image export

This commit is contained in:
aiongg
2026-10-16 14:02:08 +00:00
6 changed files with 250 additions and 16 deletions
+3 -1
View File
@@ -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. |
+6
View File
@@ -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.
+46 -9
View File
@@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
// 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<u8>), String>
(unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)
};
let n = space.components();
let decode = if jpeg || mask { None } else { decode(doc, s, n)? };
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<u8>), 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<u8>), 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<u8>), String>
png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p))
}
/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90).
fn decode(doc: &Document, s: &Stream, n: usize) -> Result<Option<Vec<[f64; 2]>>, 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()?);
+188
View File
@@ -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<u8>) -> (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<u8>, 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<u8> {
+3 -2
View File
@@ -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.<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. 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). 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).<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. 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.<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). 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<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. |
@@ -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. |
+4 -4
View File
@@ -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"