mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
Merge PR #2793: M10: preserve component Decode in lossless image export
This commit is contained in:
+3
-1
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()?);
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user