mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* M10: exported images keep their transparency, JPEG cut-outs included (#645) Export all images and Save image as wrote a JPEG with a soft mask as the bare JPEG and dropped soft masks of another size, JPEG-compressed soft masks, stencil /Mask images and colour-key /Mask arrays, so cut-out images came out on a solid white or black background. - An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is written as a PNG with it as alpha, JPEG images included (decoded with the image crate's JPEG decoder, already in the tree). JPEGs without transparency are still written byte for byte. - Soft and stencil masks of another size are scaled to the image's size; /Decode inversion is honoured and /Matte pre-blending is undone. - Image sizes are checked (positive, at most 100 Mpx) before use, so a negative /Width no longer overflows in Save image as. - Regression tests: JPEG + soft mask, scaled soft mask, stencil mask, colour key, matte, and damaged masks/sizes (all fail or panic on main). Fixes #645 Co-Authored-By: Claude Opus 5.5 <[email protected]> * M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645) Rust 1.99's clippy denies chunks_exact with a constant size, which failed the Clippy step on all CI targets. as_chunks::<2> also drops the indexing. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: DWAA1660 <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
5 files changed
+344
-34
No files matched your search
@@ -216,6 +216,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
## Log
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-10 (M10, #645):** Export all images and Save image as keep transparency. A JPEG with a soft mask (the usual cut-out photo) was written as the bare JPEG, and soft masks of another size than the image, JPEG-compressed soft masks, stencil `/Mask` images and colour-key `/Mask` arrays were dropped, so cut-outs came out on solid white or black. These images are now PNG with the mask as alpha (soft masks scaled to the image, `/Matte` un-blended); JPEGs without transparency are still written byte for byte. A negative image size no longer overflows in Save image as. JPX/JBIG2/CCITT masks are still left out. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -14,9 +14,9 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif"] }
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif"] }
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
hayro-syntax = { workspace = true }
|
||||
+185
-30
@@ -1,7 +1,8 @@
|
||||
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
|
||||
//! use, as files. JPEG images are written unchanged; other images are decoded and written as
|
||||
//! PNG (with their soft mask as alpha). Images PdfCraft can't decode yet (JPEG 2000, JBIG2,
|
||||
//! CCITT, separations) are reported, never silently left out.
|
||||
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
|
||||
//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour
|
||||
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
|
||||
//! reported, never silently left out.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -99,42 +100,75 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file for one image: its JPEG data as is, or a PNG.
|
||||
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
|
||||
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
|
||||
/// alpha, JPEG images included, so cut-out images keep their transparent background.
|
||||
fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String> {
|
||||
let f = filters(s);
|
||||
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
|
||||
match f.last().map(Vec::as_slice) {
|
||||
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => return Ok(("jpg", s.raw.to_vec())),
|
||||
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
|
||||
Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()),
|
||||
Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()),
|
||||
Some(b"DCTDecode" | b"DCT") => return Err("JPEG images inside other filters can't be exported yet".into()),
|
||||
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
|
||||
_ => {}
|
||||
}
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as usize, s.dict.int(b"Height").unwrap_or(0) as usize);
|
||||
if (w as u64) * (h as u64) > MAX_PIXELS {
|
||||
return Err(format!("{w} × {h} is too large to export"));
|
||||
}
|
||||
let data = s.decoded().map_err(|e| e.to_string())?;
|
||||
let as_is = || Ok(("jpg", s.raw.to_vec()));
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));
|
||||
let Some((w, h)) = size(w, h) else {
|
||||
return match () {
|
||||
_ if jpeg => as_is(),
|
||||
_ if w <= 0 || h <= 0 => Err("the image has no size".into()),
|
||||
_ => Err(format!("{w} × {h} is too large to export")),
|
||||
};
|
||||
};
|
||||
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
|
||||
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) as usize };
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
||||
return Err(format!("{bpc} bits per component isn't supported"));
|
||||
// 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) };
|
||||
if jpeg && alpha.is_none() && key.is_none() {
|
||||
return as_is();
|
||||
}
|
||||
let space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
|
||||
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
|
||||
let (samples, bpc) = if jpeg {
|
||||
// A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't
|
||||
// decode, or an indexed one (not valid), is written as is, without its transparency.
|
||||
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
|
||||
space = decoded;
|
||||
(samples, 8)
|
||||
} else {
|
||||
let data = s.decoded().map_err(|e| e.to_string())?;
|
||||
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
|
||||
let bpc = match bpc {
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
4 => 4,
|
||||
8 => 8,
|
||||
16 => 16,
|
||||
other => return Err(format!("{other} bits per component isn't supported")),
|
||||
};
|
||||
(unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)
|
||||
};
|
||||
let n = space.components();
|
||||
let samples = unpack(&data, w, h, n, bpc).ok_or("the image data is shorter than its size says")?;
|
||||
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
|
||||
if alpha.is_none() {
|
||||
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
|
||||
}
|
||||
if jpeg && alpha.is_none() {
|
||||
return as_is();
|
||||
}
|
||||
// Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG
|
||||
// decoder has already applied an Adobe JPEG's own inversion.
|
||||
let invert = 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)));
|
||||
let invert = invert.unwrap_or(false) ^ mask; // a stencil mask paints its 0 samples
|
||||
let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples
|
||||
let max = (1u32 << bpc.min(8)) - 1;
|
||||
let mut rgb = Vec::with_capacity(w * h * 3);
|
||||
for px in samples.chunks_exact(n) {
|
||||
let v = |i: usize| {
|
||||
let x = (px[i] as u32 * 255 / max) as u8;
|
||||
let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8;
|
||||
if invert { 255 - x } else { x }
|
||||
};
|
||||
match &space {
|
||||
@@ -142,7 +176,7 @@ 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, table) => {
|
||||
let i = px[0] as usize;
|
||||
let i = px.first().copied().unwrap_or(0) 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 {
|
||||
@@ -153,10 +187,89 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
}
|
||||
}
|
||||
}
|
||||
let alpha = soft_mask(doc, s, w, h);
|
||||
if let (Some(a), Some(matte)) = (alpha.as_deref(), matte(doc, s, &space)) {
|
||||
unpremultiply(&mut rgb, a, matte);
|
||||
}
|
||||
png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p))
|
||||
}
|
||||
|
||||
/// 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()?);
|
||||
let pixels = (w as u64).checked_mul(h as u64)?;
|
||||
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
|
||||
}
|
||||
|
||||
/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in.
|
||||
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
|
||||
if matches!(space, Space::Indexed(..)) {
|
||||
return None;
|
||||
}
|
||||
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
|
||||
if img.width() as usize != w || img.height() as usize != h {
|
||||
return None;
|
||||
}
|
||||
Some(match space {
|
||||
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
|
||||
_ => (img.into_rgb8().into_raw(), Space::Rgb),
|
||||
})
|
||||
}
|
||||
|
||||
/// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values.
|
||||
fn colour_key(doc: &Document, s: &Stream) -> Option<Vec<(u32, u32)>> {
|
||||
let m = doc.resolve(s.dict.get(b"Mask")?);
|
||||
let a = m.as_array()?;
|
||||
let v: Vec<u32> = a.iter().map(|o| doc.resolve(o).as_f64().map(|f| f.clamp(0.0, 65_535.0) as u32)).collect::<Option<_>>()?;
|
||||
(!v.is_empty() && v.len().is_multiple_of(2)).then(|| v.as_chunks::<2>().0.iter().map(|&[lo, hi]| (lo, hi)).collect())
|
||||
}
|
||||
|
||||
/// Alpha from a colour key: pixels whose every component falls in its range are transparent.
|
||||
fn keyed_alpha(samples: &[u8], n: usize, bpc: usize, key: &[(u32, u32)]) -> Option<Vec<u8>> {
|
||||
if key.len() != n {
|
||||
return None;
|
||||
}
|
||||
// 16-bit samples keep their high byte (see `unpack`), so compare the ranges' high bytes.
|
||||
let shift = if bpc == 16 { 8 } else { 0 };
|
||||
Some(
|
||||
samples
|
||||
.chunks_exact(n)
|
||||
.map(|px| {
|
||||
let hidden = px.iter().zip(key).all(|(&c, &(lo, hi))| (lo >> shift..=hi >> shift).contains(&(c as u32)));
|
||||
if hidden { 0 } else { 255 }
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The `/Matte` colour of the image's soft mask (11.6.5.3), for grey and RGB images: the colour
|
||||
/// the image samples were pre-blended with.
|
||||
fn matte(doc: &Document, s: &Stream, space: &Space) -> Option<Vec<u8>> {
|
||||
let r = s.dict.get(b"SMask")?.as_ref()?;
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(m) = &*obj else { return None };
|
||||
let a = doc.resolve(m.dict.get(b"Matte")?);
|
||||
let v: Vec<u8> =
|
||||
a.as_array()?.iter().map(|o| doc.resolve(o).as_f64().map(|f| (f.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Option<_>>()?;
|
||||
match (space, v.len()) {
|
||||
(Space::Gray, 1) => Some(vec![v[0]; 3]),
|
||||
(Space::Rgb, 3) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo a pre-blend with `matte`: c = m + (c′ − m) / α.
|
||||
fn unpremultiply(rgb: &mut [u8], alpha: &[u8], matte: Vec<u8>) {
|
||||
for (px, &a) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(alpha) {
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
for (c, &m) in px.iter_mut().zip(&matte) {
|
||||
let v = m as i32 + (*c as i32 - m as i32) * 255 / a as i32;
|
||||
*c = v.clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Space {
|
||||
Gray,
|
||||
@@ -249,21 +362,63 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
|
||||
[f(c), f(m), f(y)]
|
||||
}
|
||||
|
||||
/// The soft mask as 8-bit alpha, when it is an 8-bit gray image of the same size.
|
||||
/// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another.
|
||||
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let r = s.dict.get(b"SMask")?.as_ref()?;
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(m) = &*obj else { return None };
|
||||
if m.dict.int(b"Width")? as usize != w || m.dict.int(b"Height")? as usize != h {
|
||||
return None;
|
||||
gray_plane(doc, m, w, h)
|
||||
}
|
||||
|
||||
/// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with
|
||||
/// `/Decode [1 0]`) hide the image.
|
||||
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let r = s.dict.get(b"Mask")?.as_ref()?;
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(m) = &*obj else { return None };
|
||||
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
|
||||
}
|
||||
|
||||
/// A one-component mask image as 8-bit values (its `/Decode` inversion applied), scaled to
|
||||
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
|
||||
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
|
||||
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
|
||||
let f = filters(m);
|
||||
let mut px = match f.last().map(Vec::as_slice) {
|
||||
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => {
|
||||
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
|
||||
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
|
||||
}
|
||||
Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None,
|
||||
_ => {
|
||||
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
|
||||
let bpc = match bpc {
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
4 => 4,
|
||||
8 => 8,
|
||||
16 => 16,
|
||||
_ => return None,
|
||||
};
|
||||
let max = (1u32 << bpc.min(8)) - 1;
|
||||
unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
|
||||
}
|
||||
};
|
||||
let invert = m
|
||||
.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);
|
||||
if invert {
|
||||
px.iter_mut().for_each(|v| *v = 255 - *v);
|
||||
}
|
||||
let bpc = m.dict.int(b"BitsPerComponent").unwrap_or(8) as usize;
|
||||
if !filters(m).iter().all(|f| !matches!(f.as_slice(), b"DCTDecode" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode")) {
|
||||
return None;
|
||||
if (mw, mh) == (w, h) {
|
||||
return Some(px);
|
||||
}
|
||||
let px = unpack(&m.decoded().ok()?, w, h, 1, bpc)?;
|
||||
let max = (1u32 << bpc.min(8)) - 1;
|
||||
Some(px.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect())
|
||||
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
|
||||
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
|
||||
|
||||
@@ -461,3 +461,157 @@ fn embedded_icc_profiles_tag_the_image_colour_space() {
|
||||
let Object::Stream(s) = &*doc.get(r) else { panic!() };
|
||||
assert_eq!(icc_of(&doc, &s.dict).map(|(n, ..)| n), Some(4));
|
||||
}
|
||||
|
||||
/// Replaces page 0's image with the stream `image` builds (it can add objects, e.g. a mask).
|
||||
fn with_image(doc: &mut Document, image: impl FnOnce(&mut Document) -> Stream) {
|
||||
let page = pdfcraft_model::pages(doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let r = xo.iter().next().map(|(_, v)| v.as_ref().unwrap()).unwrap();
|
||||
let s = image(doc);
|
||||
doc.set(r, Object::Stream(s));
|
||||
}
|
||||
|
||||
fn image_dict(w: i64, h: i64, space: &str, bpc: i64) -> Dict {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(w));
|
||||
d.set(b"Height".to_vec(), Object::Int(h));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name(space));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc));
|
||||
d
|
||||
}
|
||||
|
||||
/// The exported file's extension and its pixels as RGBA.
|
||||
fn exported_rgba(doc: &Document) -> (&'static str, Vec<u8>) {
|
||||
let out = extract_images(doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
let img = &out.images[0];
|
||||
if img.extension == "jpg" {
|
||||
return ("jpg", image::load_from_memory(&img.data).unwrap().into_rgba8().into_raw());
|
||||
}
|
||||
let dec = png::Decoder::new(std::io::Cursor::new(img.data.clone()));
|
||||
let mut r = dec.read_info().unwrap();
|
||||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||||
let info = r.next_frame(&mut buf).unwrap();
|
||||
buf.truncate(info.buffer_size());
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => buf,
|
||||
png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect(),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
};
|
||||
("png", rgba)
|
||||
}
|
||||
|
||||
fn alphas(rgba: &[u8]) -> Vec<u8> {
|
||||
rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// A 4×2 grey JPEG (mid grey).
|
||||
fn real_jpeg() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode(&[128u8; 8], 4, 2, image::ExtendedColorType::L8).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_images_with_a_soft_mask_export_as_png_with_alpha() {
|
||||
// Issue #645: a cut-out photo (JPEG with an /SMask) lost its transparent background.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mask = Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0, 255, 0, 255, 255, 0, 255, 0]);
|
||||
let m = doc.add(Object::Stream(mask));
|
||||
let mut d = image_dict(4, 2, "DeviceGray", 8);
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||||
Stream::from_raw(d, real_jpeg())
|
||||
});
|
||||
let (ext, rgba) = exported_rgba(&doc);
|
||||
assert_eq!(ext, "png", "a JPEG with transparency is written as a PNG");
|
||||
assert_eq!(alphas(&rgba), [0, 255, 0, 255, 255, 0, 255, 0]);
|
||||
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0].abs_diff(128) <= 4 && p[0] == p[1] && p[1] == p[2]), "{rgba:?}");
|
||||
// Without a mask the JPEG is still written as is.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |_| {
|
||||
let mut d = image_dict(4, 2, "DeviceGray", 8);
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
Stream::from_raw(d, real_jpeg())
|
||||
});
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &real_jpeg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() {
|
||||
// A 2×1 soft mask on a 4×2 image is scaled to the image's size.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(2, 1, "DeviceGray", 8), vec![0, 255])));
|
||||
let mut d = image_dict(4, 2, "DeviceRGB", 8);
|
||||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||||
Stream::from_raw(d, vec![10; 24])
|
||||
});
|
||||
let a = alphas(&exported_rgba(&doc).1);
|
||||
assert!(a[0] < 64 && a[3] > 192 && a[4] < 64 && a[7] > 192, "{a:?}");
|
||||
|
||||
// A stencil /Mask: its 1 samples hide the image.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut md = Dict::new();
|
||||
md.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
md.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
md.set(b"Width".to_vec(), Object::Int(4));
|
||||
md.set(b"Height".to_vec(), Object::Int(2));
|
||||
md.set(b"ImageMask".to_vec(), Object::Bool(true));
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(md, vec![0b1100_0000, 0b0011_0000])));
|
||||
let mut d = image_dict(4, 2, "DeviceRGB", 8);
|
||||
d.set(b"Mask".to_vec(), Object::Ref(m));
|
||||
Stream::from_raw(d, vec![10; 24])
|
||||
});
|
||||
assert_eq!(alphas(&exported_rgba(&doc).1), [0, 0, 255, 255, 255, 255, 0, 0]);
|
||||
|
||||
// A colour-key /Mask: pure white pixels are transparent.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |_| {
|
||||
let mut d = image_dict(2, 1, "DeviceRGB", 8);
|
||||
d.set(b"Mask".to_vec(), Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec()));
|
||||
Stream::from_raw(d, vec![255, 255, 255, 255, 0, 0])
|
||||
});
|
||||
assert_eq!(exported_rgba(&doc).1, [255, 255, 255, 0, 255, 0, 0, 255]);
|
||||
|
||||
// A soft mask with /Matte: the colours are un-blended from the matte colour.
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut md = image_dict(2, 1, "DeviceGray", 8);
|
||||
md.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(1)]));
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(md, vec![255, 128])));
|
||||
let mut d = image_dict(2, 1, "DeviceGray", 8);
|
||||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||||
// Black at half alpha, pre-blended with white: 255 + (0 − 255) × 128 / 255 ≈ 127.
|
||||
Stream::from_raw(d, vec![0, 127])
|
||||
});
|
||||
let rgba = exported_rgba(&doc).1;
|
||||
assert_eq!(rgba[..4], [0, 0, 0, 255]);
|
||||
assert!(rgba[4] <= 2 && rgba[7] == 128, "{rgba:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_masks_and_sizes_never_panic() {
|
||||
for (w, h, mw, mh, data) in [(4, 2, -3, 1, vec![]), (4, 2, i64::MAX, 2, vec![0; 8]), (-4, 2, 4, 2, vec![0; 8]), (4, 2, 4, 2, vec![])] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let m = doc.add(Object::Stream(Stream::from_raw(image_dict(mw, mh, "DeviceGray", 8), data.clone())));
|
||||
let mut d = image_dict(w, h, "DeviceRGB", 8);
|
||||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||||
d.set(b"Mask".to_vec(), Object::Array(vec![Object::Real(f64::NAN), Object::Int(-5)]));
|
||||
Stream::from_raw(d, vec![10; 24])
|
||||
});
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let r = xo.iter().next().map(|(_, v)| v.as_ref().unwrap()).unwrap();
|
||||
let _ = extract_images(&doc, &[0], 0);
|
||||
let _ = image_file(&doc, r);
|
||||
}
|
||||
}
|
||||
@@ -6970,8 +6970,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/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, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks, soft mask as alpha); 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"]
|
||||
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"
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-html"
|
||||
|
||||
Reference in new issue
Block a user