mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
Mutation testing found three behaviours no test held: - eight-bit black-and-white in calibrated grey, an ICC profile of one component or a separation is packed, each keeping its colour space, and calibrated RGB of only 0 and 1 isn't (taking every array colour space for an indexed one passed every test); - mixed Group 3 (/K 1, with end-of-line codes) is stored again as Group 4 (taking /K above 0 for Group 4 passed); - a resampled Group 4 image of vertical stripes is stored as 1-bit Flate, which is smaller (ruling Flate out for any CCITT image passed). From reviews with the pdfcraft-review skill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2854 lines
147 KiB
Rust
2854 lines
147 KiB
Rust
//! The optimizer on documents with images drawn at known sizes.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||
use pdfcraft_filters::{Filter, Params};
|
||
use pdfcraft_optimize::{
|
||
Compatibility, Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Settings, SpaceCategory, audit_space,
|
||
effective_resolutions, optimize,
|
||
};
|
||
|
||
fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option<ObjRef>) -> ObjRef {
|
||
// A smooth gradient with a little texture (photo-like).
|
||
let mut px = Vec::with_capacity((w * h) as usize * n);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let v = ((x * 255 / w.max(1)) as u8).wrapping_add(((x ^ y) & 7) as u8);
|
||
px.push(v);
|
||
if n == 3 {
|
||
px.push((y * 255 / h.max(1)) as u8);
|
||
px.push(128);
|
||
}
|
||
}
|
||
}
|
||
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 as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(h as i64));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name(if n == 1 { "DeviceGray" } else { "DeviceRGB" }));
|
||
if let Some(m) = smask {
|
||
d.set(b"SMask".to_vec(), Object::Ref(m));
|
||
}
|
||
let s = if jpeg {
|
||
let mut out = Vec::new();
|
||
let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 };
|
||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 98).encode(&px, w, h, ty).unwrap();
|
||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||
Stream::from_raw(d, out)
|
||
} else {
|
||
Stream::flate(d, &px)
|
||
};
|
||
doc.add(Object::Stream(s))
|
||
}
|
||
|
||
fn page(doc: &mut Document, xobjects: &[(&str, ObjRef)], content: &str) -> ObjRef {
|
||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||
let mut x = Dict::new();
|
||
for (n, r) in xobjects {
|
||
x.set(n.as_bytes().to_vec(), Object::Ref(*r));
|
||
}
|
||
let mut res = Dict::new();
|
||
res.set(b"XObject".to_vec(), Object::Dict(x));
|
||
let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec())));
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
|
||
p.set(b"Resources".to_vec(), Object::Dict(res));
|
||
p.set(b"Contents".to_vec(), Object::Ref(c));
|
||
p.set(b"Thumb".to_vec(), Object::Ref(c));
|
||
let r = doc.add(Object::Dict(p));
|
||
doc.update_dict(pages, |d| {
|
||
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||
kids.push(Object::Ref(r));
|
||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||
})
|
||
.unwrap();
|
||
r
|
||
}
|
||
|
||
fn stream(doc: &Document, r: ObjRef) -> Stream {
|
||
match &*doc.get(r) {
|
||
Object::Stream(s) => s.clone(),
|
||
_ => panic!("not a stream"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn image_resolution_uses_the_pages_physical_unit() {
|
||
for (unit, scale) in [
|
||
(None, 1.0),
|
||
(Some(Object::Int(10)), 10.0),
|
||
(Some(Object::Real(2.5)), 2.5),
|
||
(Some(Object::Int(0)), 1.0),
|
||
(Some(Object::Int(-10)), 1.0),
|
||
(Some(Object::Real(0.5)), 1.0),
|
||
(Some(Object::name("Ten")), 1.0),
|
||
(Some(Object::Int(100_000)), pdfcraft_model::MAX_USER_UNIT),
|
||
] {
|
||
for indirect in [false, true] {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 720, 720, 1, false, None);
|
||
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
|
||
let parent = doc.get(p).as_dict().unwrap().reference(b"Parent").unwrap();
|
||
// UserUnit is page-local, unlike Resources and MediaBox.
|
||
doc.update_dict(parent, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||
if let Some(unit) = unit.clone() {
|
||
let unit = if indirect { Object::Ref(doc.add(unit)) } else { unit };
|
||
doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), unit)).unwrap();
|
||
}
|
||
let ppi = effective_resolutions(&doc, &[p])[&img];
|
||
assert!((ppi - 720.0 / scale).abs() < 1e-9, "unit={unit:?}, indirect={indirect}: {ppi}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn optimization_retains_an_image_below_threshold_on_a_scaled_page() {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 720, 720, 3, false, None);
|
||
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
|
||
doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||
// 720 pixels across ten inches is 72 ppi, already below the 225 ppi threshold.
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||
let image = stream(&doc, img);
|
||
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(720), Some(720)));
|
||
let reopened = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||
let dimensions: Vec<_> = reopened
|
||
.scan_objects()
|
||
.filter_map(|(_, object)| match &*object {
|
||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(dimensions, [(Some(720), Some(720))]);
|
||
}
|
||
|
||
#[test]
|
||
fn images_are_measured_where_drawn_and_downsampled() {
|
||
let mut doc = Document::new_empty();
|
||
// 1200 px drawn 144 pt (2 in) wide → 600 ppi.
|
||
let big = image(&mut doc, 1200, 1200, 3, false, None);
|
||
// 100 px at 144 pt → 50 ppi: left at its size.
|
||
let small = image(&mut doc, 100, 100, 1, false, None);
|
||
// With a soft mask, 800 px at 72 pt → 800 ppi.
|
||
let mask = image(&mut doc, 800, 800, 1, false, None);
|
||
let masked = image(&mut doc, 800, 800, 3, false, Some(mask));
|
||
// A high-quality JPEG at 72 ppi: recompressed, not resized.
|
||
let photo = image(&mut doc, 600, 400, 3, true, None);
|
||
// Drawn twice: 300 ppi and 75 ppi → effective 75 (never downsampled below what a use needs).
|
||
let twice = image(&mut doc, 300, 300, 3, false, None);
|
||
page(&mut doc, &[("Im1", big), ("Im2", small)], "q 144 0 0 144 36 600 cm /Im1 Do Q q 144 0 0 144 300 600 cm /Im2 Do Q");
|
||
page(
|
||
&mut doc,
|
||
&[("A", masked), ("B", photo), ("C", twice)],
|
||
"q 72 0 0 72 36 36 cm /A Do Q q 1 0 0 1 100 100 cm 600 0 0 400 0 0 cm /B Do Q q 72 0 0 72 0 0 cm /C Do Q q 288 0 0 288 300 300 cm /C Do Q",
|
||
);
|
||
let pages = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
let ppi = effective_resolutions(&doc, &pages);
|
||
assert_eq!(ppi[&big].round(), 600.0);
|
||
assert_eq!(ppi[&small].round(), 50.0);
|
||
assert_eq!(ppi[&masked].round(), 800.0);
|
||
assert_eq!(ppi[&photo].round(), 72.0);
|
||
assert_eq!(ppi[&twice].round(), 75.0, "the largest use decides");
|
||
|
||
let before = write_full(&doc, &SaveOptions::default()).unwrap().len();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images, 5, "{report:?}");
|
||
assert_eq!(report.images_resampled, 2, "{report:?}");
|
||
assert!(report.thumbnails == 2);
|
||
let b = stream(&doc, big);
|
||
assert_eq!((b.dict.int(b"Width"), b.dict.int(b"Height"), b.dict.name(b"Filter")), (Some(300), Some(300), Some(&b"DCTDecode"[..])));
|
||
let m = stream(&doc, masked);
|
||
let sm = stream(&doc, mask);
|
||
assert_eq!((m.dict.int(b"Width"), sm.dict.int(b"Width")), (Some(150), Some(150)), "the soft mask follows its image");
|
||
assert_eq!(sm.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
|
||
assert_eq!(stream(&doc, small).dict.int(b"Width"), Some(100));
|
||
assert_eq!(stream(&doc, twice).dict.int(b"Width"), Some(300), "75 ppi is below the threshold");
|
||
let p = stream(&doc, photo);
|
||
assert_eq!(p.dict.int(b"Width"), Some(600));
|
||
let after_bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||
assert!(after_bytes.len() * 3 < before, "{} → {}", before, after_bytes.len());
|
||
|
||
// The optimized file opens, and the page still shows the picture where it was.
|
||
let reopened = Document::open(Arc::new(after_bytes.clone())).unwrap();
|
||
assert_eq!(pdfcraft_annot::page_refs(&reopened).unwrap().len(), 2);
|
||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(after_bytes), pdfcraft_render::RenderConfig::default());
|
||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
// Top-left image area (36..180 x 600..744 user → y from the top 48..192): drawn, not white.
|
||
let i = ((100 * out.width + 100) * 4) as usize;
|
||
assert_ne!(&out.rgba[i..i + 3], &[255, 255, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_identity_decode_array_does_not_stop_reduce() {
|
||
// Office scanners write /Decode [0 1 0 1 0 1] (no change) on their page images (#490).
|
||
let with_decode = |doc: &mut Document, r: ObjRef, decode: Vec<Object>| {
|
||
let mut s = stream(doc, r);
|
||
s.dict.set(b"Decode".to_vec(), Object::Array(decode));
|
||
doc.set(r, Object::Stream(s));
|
||
};
|
||
let mut doc = Document::new_empty();
|
||
// 900 px drawn 216 pt (3 in) wide → 300 ppi, like a scanned page.
|
||
let scan = image(&mut doc, 900, 900, 3, true, None);
|
||
with_decode(&mut doc, scan, [0, 1, 0, 1, 0, 1].map(Object::Int).to_vec());
|
||
let gray = image(&mut doc, 900, 900, 1, false, None);
|
||
with_decode(&mut doc, gray, vec![Object::Real(0.0), Object::Real(1.0)]);
|
||
// A decode array that inverts the colours, and one of the wrong length: left alone.
|
||
let inverted = image(&mut doc, 900, 900, 3, true, None);
|
||
with_decode(&mut doc, inverted, [1, 0, 1, 0, 1, 0].map(Object::Int).to_vec());
|
||
let odd = image(&mut doc, 900, 900, 3, true, None);
|
||
with_decode(&mut doc, odd, [0, 1].map(Object::Int).to_vec());
|
||
page(
|
||
&mut doc,
|
||
&[("S", scan), ("G", gray), ("I", inverted), ("O", odd)],
|
||
"q 216 0 0 216 0 0 cm /S Do Q q 216 0 0 216 216 0 cm /G Do Q q 216 0 0 216 0 300 cm /I Do Q q 216 0 0 216 216 300 cm /O Do Q",
|
||
);
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images_resampled, 2, "{report:?}");
|
||
assert_eq!(stream(&doc, scan).dict.int(b"Width"), Some(450));
|
||
assert_eq!(stream(&doc, gray).dict.int(b"Width"), Some(450));
|
||
assert!(!stream(&doc, scan).dict.contains(b"Decode"), "the default decode array isn't carried over");
|
||
assert_eq!(stream(&doc, inverted).dict.int(b"Width"), Some(900));
|
||
assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900));
|
||
}
|
||
|
||
/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like
|
||
/// glyphs; or, with `noise`, random bits.
|
||
fn bilevel(w: u32, h: u32, noise: bool) -> Vec<u8> {
|
||
let stride = (w as usize).div_ceil(8);
|
||
let mut data = vec![0xFF; stride * h as usize];
|
||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
seed
|
||
};
|
||
let mut black = |x: usize, y: usize| {
|
||
if x < w as usize && y < h as usize {
|
||
data[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||
}
|
||
};
|
||
if noise {
|
||
for y in 0..h as usize {
|
||
for x in 0..w as usize {
|
||
if next() & 1 == 0 {
|
||
black(x, y);
|
||
}
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
// Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted
|
||
// stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner
|
||
// sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4.
|
||
for line in (4..h as usize).step_by(24) {
|
||
let mut x = 8;
|
||
while x + 16 < w as usize {
|
||
let wide = 5 + (next() % 4) as usize;
|
||
let shape = next() % 4;
|
||
// Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's
|
||
// edges are.
|
||
let mut ragged = || match next() % 8 {
|
||
0 => 0,
|
||
1 => 2,
|
||
_ => 1,
|
||
};
|
||
let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| {
|
||
for y in line + y0..line + y1 {
|
||
let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged());
|
||
for xx in left..right.saturating_sub(1) {
|
||
black(xx, y);
|
||
}
|
||
}
|
||
};
|
||
match shape {
|
||
0 => fill(0, 2, 0, 14),
|
||
1 => {
|
||
fill(0, wide, 4, 6);
|
||
fill(0, wide, 12, 14);
|
||
fill(0, 2, 4, 14);
|
||
fill(wide - 2, wide, 4, 14);
|
||
}
|
||
2 => {
|
||
fill(0, 2, 4, 14);
|
||
fill(wide - 2, wide, 6, 14);
|
||
fill(0, wide - 1, 4, 6);
|
||
}
|
||
_ => {
|
||
fill(0, 2, 7, 14);
|
||
fill(0, 2, 2, 4);
|
||
}
|
||
}
|
||
x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 };
|
||
}
|
||
}
|
||
data
|
||
}
|
||
|
||
/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with
|
||
/// `entries` added to its dictionary.
|
||
fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option<i64>, entries: &[(&str, Object)]) -> ObjRef {
|
||
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 as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(h as i64));
|
||
for (k, v) in entries {
|
||
d.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
let s = match predictor {
|
||
None => Stream::flate(d, data),
|
||
Some(predictor) => {
|
||
let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 };
|
||
let mut parms = Dict::new();
|
||
parms.set(b"Predictor".to_vec(), Object::Int(predictor));
|
||
parms.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w as i64));
|
||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap())
|
||
}
|
||
};
|
||
doc.add(Object::Stream(s))
|
||
}
|
||
|
||
fn render(doc: &Document) -> Vec<u8> {
|
||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
out.rgba.to_vec()
|
||
}
|
||
|
||
/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel.
|
||
/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and
|
||
/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one whose
|
||
/// colour space is a resource name, one wider than Group 4 encodes and a tiny one (bigger as
|
||
/// stored) are left alone, as is everything with `mono: Retain`.
|
||
#[test]
|
||
fn black_and_white_images_become_ccitt_group_4() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200, 480);
|
||
let text = bilevel(w, h, false);
|
||
let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
let decode = Object::Array(vec![1.into(), 0.into()]);
|
||
let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]);
|
||
let indexed_cs = Object::Array(vec![
|
||
Object::name("Indexed"),
|
||
Object::name("DeviceRGB"),
|
||
1.into(),
|
||
Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])),
|
||
]);
|
||
let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]);
|
||
let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
let noise =
|
||
mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
let rgb = mono_image(
|
||
&mut doc,
|
||
400,
|
||
160,
|
||
&bilevel(1200, 160, false),
|
||
None,
|
||
&[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))],
|
||
);
|
||
let wide = mono_image(
|
||
&mut doc,
|
||
40_000,
|
||
2,
|
||
&bilevel(40_000, 2, false),
|
||
None,
|
||
&[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))],
|
||
);
|
||
// A bare /Indexed is no colour space on its own: it names the page's resource, here RGB, so
|
||
// these samples are three to a pixel.
|
||
let named =
|
||
mono_image(&mut doc, 400, 160, &bilevel(1200, 160, false), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("Indexed"))]);
|
||
// 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer
|
||
// dictionary Group 4 needs.
|
||
let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap();
|
||
assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len());
|
||
let p = page(
|
||
&mut doc,
|
||
&[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny), ("X", named)],
|
||
"q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q \
|
||
q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q q 200 0 0 80 240 300 cm /X Do Q",
|
||
);
|
||
doc.update_dict(p, |d| {
|
||
if let Some(Object::Dict(res)) = d.get_mut(b"Resources") {
|
||
let mut cs = Dict::new();
|
||
cs.set(b"Indexed".to_vec(), Object::name("DeviceRGB"));
|
||
res.set(b"ColorSpace".to_vec(), Object::Dict(cs));
|
||
}
|
||
})
|
||
.unwrap();
|
||
let before = render(&doc);
|
||
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
|
||
let untouched = [noise, rgb, wide, tiny, named].map(|r| raw(&doc, r));
|
||
|
||
let mut kept = doc.clone();
|
||
let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
|
||
assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is");
|
||
assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i)));
|
||
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.images_recompressed, 4);
|
||
assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after);
|
||
for i in [gray, mask, indexed, predicted] {
|
||
let s = stream(&doc, i);
|
||
assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]));
|
||
let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
|
||
assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64)));
|
||
}
|
||
assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays");
|
||
assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays");
|
||
assert_eq!([noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)), untouched);
|
||
assert!(render(&doc) == before, "the page looks the same");
|
||
}
|
||
|
||
/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one
|
||
/// component and a DeviceN of one colourant among them, but not one of three components or two
|
||
/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or
|
||
/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more.
|
||
/// The byte totals count exactly the images stored again.
|
||
#[test]
|
||
fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let text = bilevel(w, h, false);
|
||
let icc = |doc: &mut Document, n: i64| {
|
||
let mut d = Dict::new();
|
||
d.set(b"N".to_vec(), Object::Int(n));
|
||
Object::Array(vec![Object::name("ICCBased"), Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))])
|
||
};
|
||
let device_n = |colourants: &[&str]| {
|
||
let mut tint = Dict::new();
|
||
tint.set(b"FunctionType".to_vec(), Object::Int(2));
|
||
tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()]));
|
||
tint.set(b"N".to_vec(), Object::Int(1));
|
||
Object::Array(vec![
|
||
Object::name("DeviceN"),
|
||
Object::Array(colourants.iter().map(|c| Object::name(c)).collect()),
|
||
Object::name("DeviceGray"),
|
||
Object::Dict(tint),
|
||
])
|
||
};
|
||
let one_bit = |cs: Object| [("BitsPerComponent", 1.into()), ("ColorSpace", cs)];
|
||
let icc_1 = icc(&mut doc, 1);
|
||
let icc_1 = mono_image(&mut doc, w, h, &text, None, &one_bit(icc_1));
|
||
let icc_3 = icc(&mut doc, 3);
|
||
let icc_3 = mono_image(&mut doc, w, h, &bilevel(3 * w, h, false), None, &one_bit(icc_3));
|
||
let spot = mono_image(&mut doc, w, h, &text, None, &one_bit(device_n(&["Spot"])));
|
||
let two_spots = mono_image(&mut doc, w, h, &bilevel(2 * w, h, false), None, &one_bit(device_n(&["Spot", "Other"])));
|
||
let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())];
|
||
let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1));
|
||
let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8));
|
||
// 10,000 × 8,000 is the limit itself.
|
||
let grey = one_bit(Object::name("DeviceGray"));
|
||
let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey);
|
||
let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey);
|
||
let all = [icc_1, icc_3, spot, two_spots, mask_1, mask_8, at_limit, past_limit];
|
||
let names: Vec<String> = (0..all.len()).map(|i| format!("Im{i}")).collect();
|
||
// 200 × 80 pt is 432 ppi, 1800 × 1440 pt 400 ppi: below where monochrome downsampling starts.
|
||
let content: String = (0..all.len())
|
||
.map(|i| {
|
||
if i < 6 {
|
||
format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3))
|
||
} else {
|
||
format!("q 1800 0 0 1440 0 0 cm /Im{i} Do Q ")
|
||
}
|
||
})
|
||
.collect();
|
||
page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::<Vec<_>>(), &content);
|
||
let converted = [icc_1, spot, mask_1, at_limit];
|
||
let left = [icc_3, two_spots, mask_8, past_limit];
|
||
let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::<Vec<_>>();
|
||
let (old, untouched) = (before(&doc, &converted), before(&doc, &left));
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.images_recompressed, converted.len(), "{r:?}");
|
||
for i in converted {
|
||
assert_eq!(stream(&doc, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{i:?}");
|
||
}
|
||
assert_eq!(before(&doc, &left), untouched);
|
||
let new = before(&doc, &converted);
|
||
assert_eq!((r.image_bytes_before, r.image_bytes_after), (old.iter().map(|b| b.len()).sum(), new.iter().map(|b| b.len()).sum()));
|
||
}
|
||
|
||
/// An image whose /DecodeParms are a reference, or hold numbers given by reference
|
||
/// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them
|
||
/// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored
|
||
/// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit
|
||
/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white
|
||
/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white;
|
||
/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with
|
||
/// direct parameters.
|
||
#[test]
|
||
fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() {
|
||
let build = |how: &str| {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||
let one_bit = mono_image(&mut doc, w, h, &bilevel(w, h, false), Some(15), &grey);
|
||
let black_and_white_samples = eight_bit(&bilevel(w, h, false), w, h);
|
||
let shades: Vec<u8> = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect();
|
||
let params = pdfcraft_filters::Params { predictor: 15, colors: 1, bits_per_component: 8, columns: w as i64, early_change: 1 };
|
||
let mut parms = Dict::new();
|
||
parms.set(b"Predictor".to_vec(), Object::Int(15));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w as i64));
|
||
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 as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(h as i64));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &shades).unwrap();
|
||
let eight_bit = doc.add(Object::Stream(Stream::from_raw(d.clone(), coded)));
|
||
let params = pdfcraft_filters::Params { predictor: 10, ..params };
|
||
let mut parms = Dict::new();
|
||
parms.set(b"Predictor".to_vec(), Object::Int(10));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w as i64));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap();
|
||
let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white)));
|
||
let palette = vec![0, 0, 255, 255, 255, 255];
|
||
d.set(
|
||
b"ColorSpace".to_vec(),
|
||
Object::Array(vec![
|
||
Object::name("Indexed"),
|
||
Object::name("DeviceRGB"),
|
||
1.into(),
|
||
Object::String(pdfcraft_cos::PdfString::literal(palette)),
|
||
]),
|
||
);
|
||
let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8);
|
||
let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap();
|
||
let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries)));
|
||
for r in [one_bit, eight_bit, packed, indexed] {
|
||
let mut s = stream(&doc, r);
|
||
let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) };
|
||
let made = match how {
|
||
"whole" => Object::Ref(doc.add(Object::Dict(parms))),
|
||
"numbers" => {
|
||
for k in [&b"Predictor"[..], b"Columns"] {
|
||
let v = parms.get(k).cloned().unwrap();
|
||
parms.set(k.to_vec(), Object::Ref(doc.add(v)));
|
||
}
|
||
Object::Dict(parms)
|
||
}
|
||
_ => continue,
|
||
};
|
||
s.dict.set(b"DecodeParms".to_vec(), made);
|
||
doc.set(r, Object::Stream(s));
|
||
}
|
||
// 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi.
|
||
page(
|
||
&mut doc,
|
||
&[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)],
|
||
"q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \
|
||
q 280 0 0 112 325 200 cm /D Do Q",
|
||
);
|
||
let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw);
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}");
|
||
let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r));
|
||
assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again");
|
||
assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]));
|
||
for s in &after[2..] {
|
||
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])));
|
||
}
|
||
after.map(|s| s.raw)
|
||
};
|
||
let direct = build("direct");
|
||
assert_eq!(build("whole"), direct, "the dictionary by reference");
|
||
assert_eq!(build("numbers"), direct, "the numbers inside by reference");
|
||
}
|
||
|
||
/// An image whose data decodes far past what its samples can need (a decompression bomb) is left
|
||
/// alone rather than decoded up to the 1 GiB limit: a 1-bit one that would become Group 4, an
|
||
/// eight-bit one that would be resampled, and an eight-bit one of only black and white that would
|
||
/// be packed to one bit, each with four megabytes of zeros after its samples. Stored as ASCIIHex
|
||
/// text under Flate, broken into lines of 64 digits, their samples aren't a bomb.
|
||
#[test]
|
||
fn images_whose_data_decodes_far_past_their_samples_are_left_alone() {
|
||
let (w, h) = (1200u32, 480u32);
|
||
let text = bilevel(w, h, false);
|
||
let shades: Vec<u8> = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect();
|
||
for padding in [0, 4 << 20] {
|
||
let mut doc = Document::new_empty();
|
||
let padded = |data: &[u8]| data.iter().copied().chain(std::iter::repeat_n(0, padding)).collect::<Vec<u8>>();
|
||
let grey = |bits: i64| [("BitsPerComponent", bits.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||
let one_bit = mono_image(&mut doc, w, h, &padded(&text), None, &grey(1));
|
||
let eight_bit = mono_image(&mut doc, w, h, &padded(&shades), None, &grey(8));
|
||
let packed = mono_image(&mut doc, w, h, &padded(&self::eight_bit(&text, w, h)), None, &grey(8));
|
||
// 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi.
|
||
page(
|
||
&mut doc,
|
||
&[("A", one_bit), ("B", eight_bit), ("C", packed)],
|
||
"q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q",
|
||
);
|
||
let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw);
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw);
|
||
if padding == 0 {
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}");
|
||
} else {
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
|
||
assert_eq!(after, before);
|
||
}
|
||
}
|
||
let hex_lines = |data: &[u8]| {
|
||
let digits: Vec<u8> = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||
let mut text: Vec<u8> = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect();
|
||
text.push(b'>');
|
||
pdfcraft_filters::encode_flate(&text)
|
||
};
|
||
let mut doc = Document::new_empty();
|
||
let as_hex = |doc: &mut Document, data: &[u8], bits: i64, (w, h): (u32, u32)| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(bits));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
|
||
doc.add(Object::Stream(Stream::from_raw(d, hex_lines(data))))
|
||
};
|
||
// Large enough that its hex text in lines is more than twice its samples and a byte a row
|
||
// plus 4 KiB: 2560 × 1200, drawn at 400 ppi.
|
||
let page_scan = bilevel(2560, 1200, false);
|
||
let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200));
|
||
let eight_bit = as_hex(&mut doc, &shades, 8, (w, h));
|
||
let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h));
|
||
page(
|
||
&mut doc,
|
||
&[("A", one_bit), ("B", eight_bit), ("C", packed)],
|
||
"q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q",
|
||
);
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4");
|
||
assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled");
|
||
assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed");
|
||
}
|
||
|
||
/// Monochrome Images keeping each image's compression (and resampling as by default).
|
||
const RETAIN: MonoSettings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE };
|
||
|
||
/// Eight-bit samples (0 or 255) from rows of one-bit ones.
|
||
fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||
let stride = (w as usize).div_ceil(8);
|
||
(0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect()
|
||
}
|
||
|
||
/// Eight-bit samples of only 0 and 255, dithered (Floyd–Steinberg) from a smooth picture as a
|
||
/// scanner's halftone mode gives them: no shade but black and white, in short runs.
|
||
fn dithered(w: u32, h: u32) -> Vec<u8> {
|
||
let (w, h) = (w as usize, h as usize);
|
||
let mut level: Vec<f32> = (0..w * h).map(|i| 127.5 + 127.5 * ((i % w) as f32 / 37.0).sin() * ((i / w) as f32 / 53.0).cos()).collect();
|
||
let mut out = vec![0u8; w * h];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let i = y * w + x;
|
||
let v = if level[i] < 128.0 { 0.0 } else { 255.0 };
|
||
out[i] = v as u8;
|
||
let e = level[i] - v;
|
||
if x + 1 < w {
|
||
level[i + 1] += e * 7.0 / 16.0;
|
||
}
|
||
if y + 1 < h {
|
||
if x > 0 {
|
||
level[i + w - 1] += e * 3.0 / 16.0;
|
||
}
|
||
level[i + w] += e * 5.0 / 16.0;
|
||
if x + 1 < w {
|
||
level[i + w + 1] += e / 16.0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A dithered scan stored as eight-bit grey of only black and white is smaller downsampled and
|
||
/// JPEG-compressed by the grey settings than packed to one bit, which can't shrink its dots: the
|
||
/// smaller wins, so Reduce File Size makes it no larger than with Monochrome Images on Retain.
|
||
/// Clean text is still packed to one bit at the same resolution.
|
||
#[test]
|
||
fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings() {
|
||
let (w, h) = (1200u32, 1500u32);
|
||
for (what, samples) in [("dither", dithered(w, h)), ("text", eight_bit(&bilevel(w, h, false), w, h))] {
|
||
let build = || {
|
||
let mut doc = Document::new_empty();
|
||
let img = mono_image(&mut doc, w, h, &samples, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
// 288 × 360 pt is 300 ppi, above where the grey settings downsample.
|
||
page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q");
|
||
(doc, img)
|
||
};
|
||
let (mut doc, img) = build();
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
let (mut retained, _) = build();
|
||
optimize(&mut retained, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
|
||
let (s, kept) = (stream(&doc, img), stream(&retained, img));
|
||
let bits = if what == "dither" { 8 } else { 1 };
|
||
assert_eq!(s.dict.int(b"BitsPerComponent"), Some(bits), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len());
|
||
if what == "dither" {
|
||
assert!(s.raw.len() <= kept.raw.len(), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the
|
||
/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it.
|
||
/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings
|
||
/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft
|
||
/// mask may be another size than its image): resampled to the image's new size it shrinks only a
|
||
/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins.
|
||
#[test]
|
||
fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() {
|
||
let (w, h) = (1200u32, 1500u32);
|
||
for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] {
|
||
let mut doc = Document::new_empty();
|
||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||
let noise: Vec<u8> = (0..mw * mh)
|
||
.map(|_| {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
(seed >> 24) as u8
|
||
})
|
||
.collect();
|
||
let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||
let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))];
|
||
let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey);
|
||
page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q");
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
let s = stream(&doc, img);
|
||
let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) };
|
||
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict);
|
||
}
|
||
}
|
||
|
||
/// Eight-bit grey images of only black and white (as some scanners and converters write scans)
|
||
/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array
|
||
/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples
|
||
/// are palette indices), one masked by colour key, and everything with `mono: Retain`.
|
||
#[test]
|
||
fn eight_bit_black_and_white_images_become_one_bit() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200, 480);
|
||
let grey = eight_bit(&bilevel(w, h, false), w, h);
|
||
let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| {
|
||
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 as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(h as i64));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
for (k, v) in entries {
|
||
d.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
doc.add(Object::Stream(Stream::flate(d, data)))
|
||
};
|
||
let gray_cs = ("ColorSpace", Object::name("DeviceGray"));
|
||
let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs));
|
||
let decode = Object::Array(vec![1.into(), 0.into()]);
|
||
let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]);
|
||
let mut shaded = grey.clone();
|
||
shaded[1000] = 128;
|
||
let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs));
|
||
let palette = Object::Array(vec![
|
||
Object::name("Indexed"),
|
||
Object::name("DeviceGray"),
|
||
255.into(),
|
||
Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::<Vec<_>>())),
|
||
]);
|
||
let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]);
|
||
let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]);
|
||
page(
|
||
&mut doc,
|
||
&[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)],
|
||
"q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \
|
||
q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q",
|
||
);
|
||
let before = render(&doc);
|
||
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
|
||
// Grey settings that leave grey images alone, so only this conversion acts.
|
||
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
let settings = Settings { gray: keep, ..Settings::default() };
|
||
|
||
let mut kept = doc.clone();
|
||
let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap();
|
||
assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is");
|
||
|
||
let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r));
|
||
let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum();
|
||
let r = optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!(r.images_recompressed, 2);
|
||
let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum();
|
||
assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both");
|
||
for i in [plain, inverted] {
|
||
let s = stream(&doc, i);
|
||
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])));
|
||
}
|
||
assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays");
|
||
assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched);
|
||
assert!(render(&doc) == before, "the page looks the same");
|
||
}
|
||
|
||
/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample
|
||
/// (each row starting on a new byte).
|
||
fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec<u8> {
|
||
let (w, h) = (w as usize, h as usize);
|
||
let stride = w.div_ceil(8);
|
||
let mut out = Vec::new();
|
||
for y in 0..h {
|
||
let row: Vec<u8> = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect();
|
||
let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)];
|
||
for (i, s) in row.iter().enumerate() {
|
||
packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) };
|
||
}
|
||
out.extend(packed);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only
|
||
/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4,
|
||
/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the
|
||
/// same for each component becomes grey's, and an indexed image's default one goes. Not touched:
|
||
/// RGB with any other colour, a decode array that differs between components, a colour-key mask or
|
||
/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and
|
||
/// everything with `mono: Retain`.
|
||
#[test]
|
||
fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let text = bilevel(w, h, false);
|
||
let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, entries: &[(&str, Object)]| {
|
||
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.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc));
|
||
d.set(b"ColorSpace".to_vec(), cs);
|
||
for (k, v) in entries {
|
||
d.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
doc.add(Object::Stream(Stream::flate(d, data)))
|
||
};
|
||
let rgb_cs = Object::name("DeviceRGB");
|
||
let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8);
|
||
let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]);
|
||
let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect());
|
||
let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]);
|
||
let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]);
|
||
let mut red = rgb_data.clone();
|
||
red[3000..3003].copy_from_slice(&[255, 0, 0]);
|
||
let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]);
|
||
let mut white_point = Dict::new();
|
||
white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)]));
|
||
let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]);
|
||
let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]);
|
||
let palette = |entries: &[u8]| {
|
||
Object::Array(vec![
|
||
Object::name("Indexed"),
|
||
Object::name("DeviceRGB"),
|
||
(entries.len() as i64 / 3 - 1).into(),
|
||
Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())),
|
||
])
|
||
};
|
||
let blue_white = palette(&[0, 0, 255, 255, 255, 255]);
|
||
let indices = samples_of(&text, w, h, [&[0], &[1]], 8);
|
||
let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]);
|
||
let default_decode = Object::Array(vec![0.into(), 255.into()]);
|
||
let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]);
|
||
let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]);
|
||
let mut third = indices.clone();
|
||
third[3000] = 2;
|
||
let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]);
|
||
let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]);
|
||
let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode];
|
||
// 200 × 80 pt is 432 ppi: below where monochrome downsampling starts.
|
||
let names: Vec<String> = (0..all.len()).map(|i| format!("Im{i}")).collect();
|
||
let content: String =
|
||
(0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect();
|
||
page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::<Vec<_>>(), &content);
|
||
let before = render(&doc);
|
||
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
|
||
// Colour and grey settings that leave images alone, so only this conversion acts.
|
||
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
let settings = Settings { color: keep, gray: keep, ..Settings::default() };
|
||
|
||
let mut kept = doc.clone();
|
||
let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is");
|
||
|
||
let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r));
|
||
let r = optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}");
|
||
for (i, cs) in [
|
||
(rgb, Object::name("DeviceGray")),
|
||
(rgb_inverted, Object::name("DeviceGray")),
|
||
(blue, blue_white.clone()),
|
||
(blue_decoded, blue_white.clone()),
|
||
(blue_4, blue_white),
|
||
] {
|
||
let s = stream(&doc, i);
|
||
assert_eq!(
|
||
(s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")),
|
||
(Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)),
|
||
"{i:?}"
|
||
);
|
||
assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}");
|
||
let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None };
|
||
assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}");
|
||
}
|
||
assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched);
|
||
assert!(render(&doc) == before, "the page looks the same");
|
||
}
|
||
|
||
/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated
|
||
/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated
|
||
/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is.
|
||
#[test]
|
||
fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let text = bilevel(w, h, false);
|
||
let image = |doc: &mut Document, data: &[u8], cs: Object| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), cs);
|
||
doc.add(Object::Stream(Stream::flate(d, data)))
|
||
};
|
||
let mut white_point = Dict::new();
|
||
white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)]));
|
||
let mut profile = Dict::new();
|
||
profile.set(b"N".to_vec(), Object::Int(1));
|
||
let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new())));
|
||
let mut tint = Dict::new();
|
||
tint.set(b"FunctionType".to_vec(), Object::Int(2));
|
||
tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()]));
|
||
tint.set(b"N".to_vec(), Object::Int(1));
|
||
let spaces = [
|
||
Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]),
|
||
Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]),
|
||
Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]),
|
||
];
|
||
let grey = samples_of(&text, w, h, [&[0], &[255]], 8);
|
||
let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs));
|
||
let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]);
|
||
let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb);
|
||
let names = ["A", "B", "C", "D"];
|
||
// 200 × 80 pt is 432 ppi: below where monochrome downsampling starts.
|
||
let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect();
|
||
page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::<Vec<_>>(), &content);
|
||
let untouched = stream(&doc, cal_rgb).raw;
|
||
// Colour and grey settings that leave images alone, so only the packing acts.
|
||
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap();
|
||
for (r, cs) in packed.iter().zip(&spaces) {
|
||
let s = stream(&doc, *r);
|
||
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}");
|
||
}
|
||
assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB");
|
||
}
|
||
|
||
/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image
|
||
/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing.
|
||
#[test]
|
||
fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() {
|
||
let (w, h) = (1200u32, 480u32);
|
||
let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize];
|
||
let mut doc = Document::new_empty();
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"K".to_vec(), Object::Int(-1));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap())));
|
||
// 144 × 57.6 pt is 600 ppi.
|
||
page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q");
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.images_resampled, 1, "{r:?}");
|
||
let s = stream(&doc, img);
|
||
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..])));
|
||
}
|
||
|
||
/// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses,
|
||
/// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 §8.6.5.6), from a page's
|
||
/// resources or a form's own, as grey would then go through DefaultGray, or no longer through
|
||
/// DefaultRGB; or with a soft mask whose `/Matte` has three components, one for each of RGB's. A
|
||
/// form drawn where they are in force counts too, though it has resources of its own. The same
|
||
/// image drawn plainly, or with a soft mask and no `/Matte`, becomes grey.
|
||
#[test]
|
||
fn black_and_white_rgb_stays_rgb_where_grey_would_be_drawn_otherwise() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8);
|
||
let image = |doc: &mut Document, cs: &str, data: &[u8], entries: &[(&str, Object)]| {
|
||
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.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name(cs));
|
||
for (k, v) in entries {
|
||
d.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
doc.add(Object::Stream(Stream::flate(d, data)))
|
||
};
|
||
let opaque = vec![255u8; (w * h) as usize];
|
||
let plain_mask = image(&mut doc, "DeviceGray", &opaque, &[]);
|
||
let matte_mask = image(&mut doc, "DeviceGray", &opaque, &[("Matte", Object::Array(vec![0.into(); 3]))]);
|
||
let plain = image(&mut doc, "DeviceRGB", &rgb_data, &[]);
|
||
let masked = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(plain_mask))]);
|
||
let matted = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(matte_mask))]);
|
||
let [under_gray, under_rgb, shared, in_form, nested] = [(); 5].map(|_| image(&mut doc, "DeviceRGB", &rgb_data, &[]));
|
||
let mut white_point = Dict::new();
|
||
white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)]));
|
||
let spaces = |key: &str| {
|
||
let family = if key == "DefaultGray" { "CalGray" } else { "CalRGB" };
|
||
let mut c = Dict::new();
|
||
c.set(key.as_bytes().to_vec(), Object::Array(vec![Object::name(family), Object::Dict(white_point.clone())]));
|
||
Object::Dict(c)
|
||
};
|
||
let draw = |names: &[&str]| names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect::<String>();
|
||
let form = |doc: &mut Document, im: ObjRef, spaces: Option<Object>| {
|
||
let mut xobjects = Dict::new();
|
||
xobjects.set(b"Im".to_vec(), Object::Ref(im));
|
||
let mut resources = Dict::new();
|
||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||
if let Some(spaces) = spaces {
|
||
resources.set(b"ColorSpace".to_vec(), spaces);
|
||
}
|
||
let mut f = Dict::new();
|
||
f.set(b"Type".to_vec(), Object::name("XObject"));
|
||
f.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
|
||
f.set(b"Resources".to_vec(), Object::Dict(resources));
|
||
doc.add(Object::Stream(Stream::from_raw(f, draw(&["Im"]).into_bytes())))
|
||
};
|
||
// Drawn plainly first, then where DefaultGray is in force: the second walk must happen.
|
||
let plain_form = form(&mut doc, nested, None);
|
||
page(&mut doc, &[("A", plain), ("B", masked), ("C", matted), ("D", shared), ("E", plain_form)], &draw(&["A", "B", "C", "D", "E"]));
|
||
for (key, xobjects) in [("DefaultGray", vec![("A", under_gray), ("B", shared), ("C", plain_form)]), ("DefaultRGB", vec![("A", under_rgb)])] {
|
||
let names: Vec<&str> = xobjects.iter().map(|(n, _)| *n).collect();
|
||
let p = page(&mut doc, &xobjects, &draw(&names));
|
||
doc.update_dict(p, |d| {
|
||
let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap();
|
||
res.set(b"ColorSpace".to_vec(), spaces(key));
|
||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||
})
|
||
.unwrap();
|
||
}
|
||
// A form with its own resources, DefaultGray among them, on a page without any.
|
||
let gray_form = form(&mut doc, in_form, Some(spaces("DefaultGray")));
|
||
page(&mut doc, &[("F", gray_form)], "/F Do");
|
||
let kept = [under_gray, under_rgb, shared, in_form, nested, matted];
|
||
let before = kept.map(|r| stream(&doc, r));
|
||
// Colour and grey settings that leave images alone, so only this conversion acts.
|
||
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
let settings = Settings { color: keep, gray: keep, ..Settings::default() };
|
||
let r = optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 2), "{r:?}");
|
||
for i in [plain, masked] {
|
||
let s = stream(&doc, i);
|
||
assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}");
|
||
}
|
||
for (i, b) in kept.iter().zip(&before) {
|
||
let s = stream(&doc, *i);
|
||
assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}");
|
||
}
|
||
|
||
// A use that couldn't be followed (forms nested too deep) may be where DefaultGray is in force.
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, "DeviceRGB", &rgb_data, &[]);
|
||
let deep = nested_forms(&mut doc, img, 13);
|
||
page(&mut doc, &[("Im", img), ("F", deep)], &draw(&["Im", "F"]));
|
||
let before = stream(&doc, img);
|
||
optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!(stream(&doc, img).raw, before.raw, "a use not followed");
|
||
}
|
||
|
||
/// Black-and-white images above 450 ppi are averaged down to 300 ppi, by default: 1-bit Flate, CCITT
|
||
/// Group 4 (alone and behind ASCIIHexDecode), an image mask (its inverting decode array stays), and
|
||
/// eight-bit grey and RGB of only black and white (the RGB also the other way round, with a decode
|
||
/// array that inverts it, so its ties are 1s), all drawn at 600 ppi, are stored at half the size (as
|
||
/// Group 4, or 1-bit Flate when that is smaller, as it is on this clean text), each new pixel the
|
||
/// majority of the 2 × 2 it covers (a tie black, or for the mask painted). Left at their size: an image at 400 ppi (stored
|
||
/// again as Group 4), a Group 4 one at 400 ppi (not even decoded), one behind ASCIIHexDecode at
|
||
/// 400 ppi (stored again as Group 4 alone), and one with a soft mask. The page looks the same at
|
||
/// screen size. With `downsample` off nothing is resampled; with Retain, Flate stays Flate, CCITT
|
||
/// stays CCITT, and eight-bit grey and RGB are left to the grey and colour settings.
|
||
#[test]
|
||
fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() {
|
||
let mut doc = Document::new_empty();
|
||
let (w, h) = (1200u32, 480u32);
|
||
let text = bilevel(w, h, false);
|
||
let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||
let g4 = |doc: &mut Document, entries: &[(&str, Object)]| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")]));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"K".to_vec(), Object::Int(-1));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
|
||
d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Dict(parms)]));
|
||
for (k, v) in entries {
|
||
d.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&text, w, h).unwrap())))
|
||
};
|
||
// Group 4 behind ASCIIHexDecode, its parameters second in the array.
|
||
let behind_hex = |doc: &mut Document| {
|
||
let r = g4(doc, &grey);
|
||
let s = stream(doc, r);
|
||
let mut d = s.dict.clone();
|
||
let parms = d.get(b"DecodeParms").and_then(Object::as_array).and_then(|a| a.first()).cloned().unwrap();
|
||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")]));
|
||
d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms]));
|
||
let hex = s.raw.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
|
||
doc.set(r, Object::Stream(Stream::from_raw(d, hex)));
|
||
r
|
||
};
|
||
let flate = mono_image(&mut doc, w, h, &text, None, &grey);
|
||
let fax = g4(&mut doc, &grey);
|
||
let hex_fax = behind_hex(&mut doc);
|
||
let hex_at_400 = behind_hex(&mut doc);
|
||
let decode = Object::Array(vec![1.into(), 0.into()]);
|
||
let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]);
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h))));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||
let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8))));
|
||
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec()));
|
||
let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8))));
|
||
let at_400 = mono_image(&mut doc, w, h, &text, None, &grey);
|
||
let fax_at_400 = g4(&mut doc, &grey);
|
||
let mut sm = Dict::new();
|
||
sm.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
sm.set(b"Width".to_vec(), Object::Int(2));
|
||
sm.set(b"Height".to_vec(), Object::Int(1));
|
||
sm.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
sm.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let smask = doc.add(Object::Stream(Stream::flate(sm, &[255, 200])));
|
||
let soft = mono_image(&mut doc, w, h, &text, None, &[grey[0].clone(), grey[1].clone(), ("SMask", Object::Ref(smask))]);
|
||
// 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi.
|
||
page(
|
||
&mut doc,
|
||
&[
|
||
("A", flate),
|
||
("B", fax),
|
||
("C", mask),
|
||
("D", eight),
|
||
("E", at_400),
|
||
("F", fax_at_400),
|
||
("G", soft),
|
||
("H", hex_fax),
|
||
("I", hex_at_400),
|
||
("J", rgb),
|
||
("K", rgb_inverted),
|
||
],
|
||
"q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \
|
||
q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \
|
||
q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \
|
||
q 144 0 0 57.6 430 480 cm /K Do Q",
|
||
);
|
||
let before = render(&doc);
|
||
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
|
||
let size = |doc: &Document, r: ObjRef| {
|
||
let d = stream(doc, r).dict;
|
||
(d.int(b"Width").unwrap(), d.int(b"Height").unwrap())
|
||
};
|
||
// Each new pixel: 1 when more than two of the 2 × 2 it covers are, or two when 1 is the dark
|
||
// sample (the mask's decode array inverts it, so its painted part is 1).
|
||
let stride = (w as usize).div_ceil(8);
|
||
let bit = |x: usize, y: usize| text[y * stride + x / 8] & (0x80 >> (x % 8)) != 0;
|
||
let halve = |dark_one: bool| {
|
||
let mut out = vec![0u8; (w as usize / 2).div_ceil(8) * h as usize / 2];
|
||
for y in 0..h as usize / 2 {
|
||
for x in 0..w as usize / 2 {
|
||
let ones = [(0, 0), (1, 0), (0, 1), (1, 1)].iter().filter(|(dx, dy)| bit(2 * x + dx, 2 * y + dy)).count();
|
||
if ones > 2 || (dark_one && ones == 2) {
|
||
out[y * (w as usize / 2).div_ceil(8) + x / 8] |= 0x80 >> (x % 8);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
};
|
||
let (halved, halved_mask) = (halve(false), halve(true));
|
||
assert_ne!(halved, halved_mask, "the picture has ties");
|
||
|
||
// The grey and colour settings left alone too: an image of more bits would otherwise take
|
||
// them when smaller.
|
||
let mut off = doc.clone();
|
||
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE };
|
||
let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap();
|
||
assert_eq!(r.images_resampled, 0, "{r:?}");
|
||
assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480)));
|
||
|
||
let mut kept = doc.clone();
|
||
let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
|
||
// Not the inverted RGB: the colour settings leave an image with a decode array alone.
|
||
assert_eq!(r.images_resampled, 6, "{r:?}");
|
||
assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate");
|
||
for i in [fax, hex_fax] {
|
||
assert_eq!(stream(&kept, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "and CCITT stays CCITT: {i:?}");
|
||
}
|
||
assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes");
|
||
for i in [flate, fax, hex_fax] {
|
||
assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((600, 240), halved.clone()), "{i:?}");
|
||
}
|
||
let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent"));
|
||
assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings");
|
||
|
||
let untouched = [fax_at_400].map(|r| raw(&doc, r));
|
||
let hex_len = raw(&doc, hex_at_400).len();
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (7, 3), "{r:?}");
|
||
assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray")));
|
||
for i in [flate, fax, mask, eight, hex_fax, rgb] {
|
||
let s = stream(&doc, i);
|
||
assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((600, 240), if i == mask { None } else { Some(1) }));
|
||
assert!(matches!(s.dict.name(b"Filter"), Some(b"CCITTFaxDecode" | b"FlateDecode")), "{i:?}");
|
||
assert_eq!(doc.decoded_image(&s).unwrap(), if i == mask { halved_mask.clone() } else { halved.clone() }, "{i:?}");
|
||
}
|
||
assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays");
|
||
// Grey now, with grey's decode pair, which makes 1 black: its ties are black too.
|
||
let s = stream(&doc, rgb_inverted);
|
||
let inverting = Object::Array(vec![1.into(), 0.into()]);
|
||
assert_eq!(
|
||
(size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")),
|
||
((600, 240), Some(&Object::name("DeviceGray")), Some(&inverting))
|
||
);
|
||
assert_eq!(doc.decoded_image(&s).unwrap(), halved.iter().map(|b| !b).collect::<Vec<u8>>(), "the same picture, ties black");
|
||
for i in [at_400, soft, hex_at_400] {
|
||
assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}");
|
||
}
|
||
assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped");
|
||
assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept");
|
||
assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched);
|
||
let after = render(&doc);
|
||
let differ = before.iter().zip(&after).filter(|(a, b)| a.abs_diff(**b) > 64).count();
|
||
assert!(differ * 200 < before.len(), "{differ} of {} channel values differ visibly", before.len());
|
||
}
|
||
|
||
/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels
|
||
/// would cover both black and white and taking the sample covering more would turn its tones
|
||
/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi.
|
||
/// Text at 600 ppi, which covers both only along its edges, is averaged down. Rows no wider than
|
||
/// a fax row (`MAX_CCITT_COLUMNS`) are averaged down too, but not one column wider: the averaging
|
||
/// keeps a counter for each column.
|
||
#[test]
|
||
fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() {
|
||
let (w, h) = (1200u32, 480u32);
|
||
let stride = (w as usize).div_ceil(8);
|
||
let pattern = |white: &dyn Fn(usize, usize) -> bool| {
|
||
let mut data = vec![0u8; stride * h as usize];
|
||
for y in 0..h as usize {
|
||
for x in 0..w as usize {
|
||
if white(x, y) {
|
||
data[y * stride + x / 8] |= 0x80 >> (x % 8);
|
||
}
|
||
}
|
||
}
|
||
data
|
||
};
|
||
let diffused = dithered(w, h);
|
||
let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||
let mut doc = Document::new_empty();
|
||
let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey);
|
||
let halftones =
|
||
[pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)]
|
||
.map(|data| mono_image(&mut doc, w, h, &data, None, &grey));
|
||
// 144 × 57.6 pt is 600 ppi.
|
||
let names = ["T", "A", "B", "C"];
|
||
let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect();
|
||
page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::<Vec<_>>(), &content);
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
let size = |r: ObjRef| {
|
||
let d = stream(&doc, r).dict;
|
||
(d.int(b"Width"), d.int(b"Height"))
|
||
};
|
||
assert_eq!(size(text), (Some(600), Some(240)), "text");
|
||
for (i, r) in halftones.iter().enumerate() {
|
||
assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}");
|
||
}
|
||
|
||
for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] {
|
||
let mut doc = Document::new_empty();
|
||
let white = vec![0xFF; (columns as usize).div_ceil(8) * 2];
|
||
let img = mono_image(&mut doc, columns, 2, &white, None, &grey);
|
||
// Far above 450 ppi both ways.
|
||
page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q");
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}");
|
||
}
|
||
}
|
||
|
||
/// A fax image behind a decompression bomb is left alone: the filters before its decoder may
|
||
/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB
|
||
/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its
|
||
/// code; without them it is averaged down.
|
||
#[test]
|
||
fn a_fax_image_behind_a_decompression_bomb_is_left_alone() {
|
||
let (w, h) = (1200u32, 480u32);
|
||
let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap();
|
||
for padding in [0, 4 << 20] {
|
||
let mut doc = Document::new_empty();
|
||
let mut code = g4.clone();
|
||
code.resize(g4.len() + padding, 0);
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")]));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"K".to_vec(), Object::Int(-1));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
|
||
d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
|
||
let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code))));
|
||
// 144 × 57.6 pt is 600 ppi.
|
||
page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q");
|
||
let before = stream(&doc, img).raw;
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
if padding == 0 {
|
||
assert_eq!(r.images_resampled, 1, "{r:?}");
|
||
} else {
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
|
||
assert_eq!(stream(&doc, img).raw, before);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A CCITT image `w` × `h` coded as Group 4 from `bits`, behind ASCIIHexDecode when `hex`.
|
||
fn g4_image(doc: &mut Document, bits: &[u8], (w, h): (u32, u32), hex: bool) -> ObjRef {
|
||
let g4 = pdfcraft_filters::encode_g4(bits, w, h).unwrap();
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"K".to_vec(), Object::Int(-1));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
|
||
let data = if hex {
|
||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")]));
|
||
d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
|
||
g4.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect()
|
||
} else {
|
||
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
g4
|
||
};
|
||
doc.add(Object::Stream(Stream::from_raw(d, data)))
|
||
}
|
||
|
||
/// What Monochrome Images leaves alone and what it may still do. A Group 4 halftone above 450 ppi
|
||
/// isn't resampled, so it stays byte for byte: alone, by default; behind ASCIIHexDecode, with
|
||
/// Retain (by default it is stored again as Group 4 alone). Under Retain a resampled Flate scan
|
||
/// stays Flate though Group 4 would be smaller. A busy Group 4 image behind ASCIIHexDecode, whose
|
||
/// code is more than three times its raster, decodes within a fax code's bound and is stored
|
||
/// again as Group 4 alone. Sixteen-bit grey of only 0x0000 and 0xFFFF isn't eight-bit black and
|
||
/// white, and keeps its bytes.
|
||
#[test]
|
||
fn monochrome_images_keep_what_they_should() {
|
||
let (w, h) = (1200u32, 480u32);
|
||
let stride = (w as usize).div_ceil(8);
|
||
let checker: Vec<u8> = (0..h as usize).flat_map(|y| vec![if y % 2 == 0 { 0xAA } else { 0x55 }; stride]).collect();
|
||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
seed
|
||
};
|
||
// Twelve dark bands a row, their edges ragged by up to five samples, as a scan's are.
|
||
let mut ragged = vec![0xFFu8; stride * h as usize];
|
||
for y in 0..h as usize {
|
||
for band in 0..12 {
|
||
let (x0, x1) = (band * 100 + (next() % 5) as usize, band * 100 + 50 + (next() % 5) as usize);
|
||
for x in x0..x1.min(w as usize) {
|
||
ragged[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||
}
|
||
}
|
||
}
|
||
let busy: Vec<u8> = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect();
|
||
let build = || {
|
||
let mut doc = Document::new_empty();
|
||
let alone = g4_image(&mut doc, &checker, (w, h), false);
|
||
let wrapped = g4_image(&mut doc, &checker, (w, h), true);
|
||
let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||
let scan = mono_image(&mut doc, w, h, &ragged, None, &grey);
|
||
let fax = g4_image(&mut doc, &busy, (256, 256), true);
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(16));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let samples: Vec<u8> = eight_bit(&bilevel(w, h, false), w, h).iter().flat_map(|v| [*v, *v]).collect();
|
||
let deep = doc.add(Object::Stream(Stream::flate(d, &samples)));
|
||
// 144 × 57.6 pt is 600 ppi; 46.08 pt for 256 samples is 400 ppi.
|
||
page(
|
||
&mut doc,
|
||
&[("A", alone), ("B", wrapped), ("C", scan), ("D", fax), ("E", deep)],
|
||
"q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 144 0 0 57.6 20 600 cm /C Do Q \
|
||
q 46.08 0 0 46.08 200 600 cm /D Do Q q 144 0 0 57.6 20 500 cm /E Do Q",
|
||
);
|
||
(doc, [alone, wrapped, scan, fax, deep])
|
||
};
|
||
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
|
||
let filters = |doc: &Document, r: ObjRef| stream(doc, r).filters().len();
|
||
|
||
let (mut doc, [alone, wrapped, scan, fax, deep]) = build();
|
||
let before = [alone, deep].map(|r| raw(&doc, r));
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey");
|
||
assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default");
|
||
assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller");
|
||
assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image");
|
||
|
||
let (mut doc, [_, wrapped, scan, _, _]) = build();
|
||
let before = raw(&doc, wrapped);
|
||
let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
|
||
assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is");
|
||
let s = stream(&doc, scan);
|
||
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..])), "Retain: Flate stays Flate: {r:?}");
|
||
}
|
||
|
||
/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first,
|
||
/// with ITU-T T.4's terminating codes for the few lengths used here.
|
||
fn group_3(rows: &[&[u32]], mixed: bool) -> Vec<u8> {
|
||
let code = |white: bool, run: u32| match (white, run) {
|
||
(true, 0) => "00110101",
|
||
(true, 8) => "10011",
|
||
(true, 16) => "101010",
|
||
(true, 32) => "00011011",
|
||
(false, 8) => "000101",
|
||
(false, 16) => "0000010111",
|
||
(false, 32) => "000001101010",
|
||
_ => panic!("no code here for a run of {run}"),
|
||
};
|
||
let mut bits = String::new();
|
||
for row in rows {
|
||
// Mixed coding (/K above 0): an end-of-line code, then a 1 bit, the row one-dimensional.
|
||
if mixed {
|
||
bits.push_str("0000000000011");
|
||
}
|
||
row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run)));
|
||
}
|
||
bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect()
|
||
}
|
||
|
||
/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the
|
||
/// pixel on the page); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with
|
||
/// end-of-line codes) alike.
|
||
#[test]
|
||
fn group_3_fax_images_become_group_4() {
|
||
let (w, h) = (64u32, 64u32);
|
||
let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect();
|
||
// White is 1, as CCITTFaxDecode gives it without /BlackIs1.
|
||
let mut bits = Vec::new();
|
||
for row in &runs {
|
||
let mut packed = [0u8; 8];
|
||
let mut x = 0;
|
||
for (i, run) in row.iter().enumerate() {
|
||
for p in x..x + *run as usize {
|
||
if i % 2 == 0 {
|
||
packed[p / 8] |= 0x80 >> (p % 8);
|
||
}
|
||
}
|
||
x += *run as usize;
|
||
}
|
||
bits.extend(packed);
|
||
}
|
||
for k in [0, 1] {
|
||
let g3 = group_3(&runs, k > 0);
|
||
let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line: k > 0, ..Default::default() };
|
||
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}: the Group 3 data is the picture");
|
||
|
||
let mut doc = Document::new_empty();
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
d.set(b"Width".to_vec(), Object::Int(w.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"K".to_vec(), Object::Int(k));
|
||
parms.set(b"EndOfLine".to_vec(), Object::Bool(k > 0));
|
||
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
|
||
parms.set(b"Rows".to_vec(), Object::Int(h.into()));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
|
||
page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q");
|
||
let before = render(&doc);
|
||
|
||
let mut kept = doc.clone();
|
||
optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
|
||
assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}: Retain keeps Group 3");
|
||
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}: {r:?}");
|
||
let s = stream(&doc, fax);
|
||
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}: Group 4");
|
||
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
|
||
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}: the same picture");
|
||
assert!(render(&doc) == before, "/K {k}: the page looks the same");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn settings_choose_what_happens() {
|
||
let mut doc = Document::new_empty();
|
||
let big = image(&mut doc, 1200, 600, 1, false, None);
|
||
page(&mut doc, &[("Im1", big)], "q 144 0 0 72 36 600 cm /Im1 Do Q");
|
||
let mut keep = doc.clone();
|
||
// Off: nothing changes (but the thumbnail goes).
|
||
let off = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
|
||
let r = optimize(&mut keep, &Settings { color: off, gray: off, ..Settings::default() }).unwrap();
|
||
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0));
|
||
// Lossless: resampled to 300 ppi, Flate.
|
||
let lossless = ImageSettings { downsample: true, target_ppi: 300.0, above_ppi: 450.0, compression: Compression::Flate };
|
||
let r = optimize(&mut doc, &Settings { gray: lossless, ..Settings::default() }).unwrap();
|
||
assert_eq!(r.images_resampled, 1);
|
||
let s = stream(&doc, big);
|
||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height"), s.dict.name(b"Filter")), (Some(600), Some(300), Some(&b"FlateDecode"[..])));
|
||
}
|
||
|
||
#[test]
|
||
fn discards_and_clean_up() {
|
||
let mut doc = Document::new_empty();
|
||
let pages = page(&mut doc, &[], "BT /F1 12 Tf 72 720 Td (Hello world, hello world, hello world, hello world) Tj ET");
|
||
let root = doc.root().unwrap();
|
||
let mut vp = Dict::new();
|
||
vp.set(b"PrintScaling".to_vec(), Object::name("None"));
|
||
vp.set(b"Duplex".to_vec(), Object::name("Simplex"));
|
||
vp.set(b"HideToolbar".to_vec(), Object::Bool(true));
|
||
let st = doc.add(Object::Dict(Dict::new()));
|
||
doc.update_dict(root, |c| {
|
||
c.set(b"ViewerPreferences".to_vec(), Object::Dict(vp));
|
||
c.set(b"StructTreeRoot".to_vec(), Object::Ref(st));
|
||
c.set(b"MarkInfo".to_vec(), Object::Dict(Dict::new()));
|
||
})
|
||
.unwrap();
|
||
doc.update_dict(pages, |p| p.set(b"StructParents".to_vec(), Object::Int(0))).unwrap();
|
||
let settings = Settings { discard_tags: true, discard_print_settings: true, ..Settings::default() };
|
||
let r = optimize(&mut doc, &settings).unwrap();
|
||
assert!(r.tags_removed);
|
||
assert_eq!((r.print_settings, r.thumbnails, r.streams_compressed), (2, 1, 1));
|
||
let c = doc.get(root).as_dict().cloned().unwrap();
|
||
assert!(!c.contains(b"StructTreeRoot") && !c.contains(b"MarkInfo"));
|
||
let vp = c.get(b"ViewerPreferences").and_then(|v| v.as_dict().cloned()).unwrap();
|
||
assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay");
|
||
}
|
||
|
||
#[test]
|
||
fn space_audit_shares_the_file_out_by_kind() {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 300, 300, 3, false, None);
|
||
page(&mut doc, &[("Im0", img)], "q 300 0 0 300 0 0 cm /Im0 Do Q BT ET");
|
||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||
let audit = audit_space(&doc, bytes.len() as u64);
|
||
assert_eq!(audit.len(), SpaceCategory::ALL.len());
|
||
let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().clone();
|
||
let (images, content, overhead) = (get(SpaceCategory::Images), get(SpaceCategory::ContentStreams), get(SpaceCategory::DocumentOverhead));
|
||
assert!(images.percent > 80.0, "{audit:?}");
|
||
assert!(content.bytes > 0 && overhead.bytes > 0, "{audit:?}");
|
||
assert_eq!(audit.iter().map(|u| u.bytes).sum::<u64>(), bytes.len() as u64);
|
||
assert!((audit.iter().map(|u| u.percent).sum::<f64>() - 100.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn space_audit_counts_images_a_content_stream_dictionary_points_at_as_images() {
|
||
// Content added with Edit ▸ Add content records its image in the content stream's
|
||
// dictionary (/PCAdded); the image is still an image, not part of the content stream.
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 300, 300, 3, false, None);
|
||
let p = page(&mut doc, &[("Im0", img)], "q 300 0 0 300 0 0 cm /Im0 Do Q");
|
||
let c = doc.get(p).as_dict().and_then(|d| d.reference(b"Contents")).unwrap();
|
||
let mut added = Dict::new();
|
||
added.set(b"Kind".to_vec(), Object::name("Image"));
|
||
added.set(b"Image".to_vec(), Object::Ref(img));
|
||
doc.update_dict(c, |d| d.set(b"PCAdded".to_vec(), Object::Dict(added))).unwrap();
|
||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||
let audit = audit_space(&doc, bytes.len() as u64);
|
||
let images = audit.iter().find(|u| u.category == SpaceCategory::Images).unwrap();
|
||
assert!(images.percent > 80.0, "{audit:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn space_audit_counts_what_fields_and_comments_draw_with_by_kind() {
|
||
// A font or image a form field or comment draws with is still a font or image. The form
|
||
// was claimed first, so its fonts made a fillable form look like mostly "Forms".
|
||
let mut doc = Document::new_empty();
|
||
let p = page(&mut doc, &[], "BT ET");
|
||
let font = |doc: &mut Document| {
|
||
let file = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![7; 40_000])));
|
||
let mut desc = Dict::new();
|
||
desc.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||
desc.set(b"FontFile2".to_vec(), Object::Ref(file));
|
||
let desc = doc.add(Object::Dict(desc));
|
||
let mut f = Dict::new();
|
||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||
f.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||
f.set(b"FontDescriptor".to_vec(), Object::Ref(desc));
|
||
doc.add(Object::Dict(f))
|
||
};
|
||
let (field_font, comment_font) = (font(&mut doc), font(&mut doc));
|
||
let icon = image(&mut doc, 200, 200, 3, false, None);
|
||
let appearance = |doc: &mut Document, kind: &str, name: &str, r: ObjRef| {
|
||
let mut entries = Dict::new();
|
||
entries.set(name.as_bytes().to_vec(), Object::Ref(r));
|
||
let mut res = Dict::new();
|
||
res.set(kind.as_bytes().to_vec(), Object::Dict(entries));
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, b"q Q".to_vec()))))
|
||
};
|
||
let annot = |doc: &mut Document, subtype: &str, ap: Object| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||
d.set(b"P".to_vec(), Object::Ref(p));
|
||
let mut aps = Dict::new();
|
||
aps.set(b"N".to_vec(), ap);
|
||
d.set(b"AP".to_vec(), Object::Dict(aps));
|
||
Object::Ref(doc.add(Object::Dict(d)))
|
||
};
|
||
// A text field's appearance uses the form's default font; a check box's "on" state (one of
|
||
// a dictionary of states) draws an image; a free-text comment has a font of its own.
|
||
let text_ap = appearance(&mut doc, "Font", "F1", field_font);
|
||
let text = annot(&mut doc, "Widget", text_ap);
|
||
let mut states = Dict::new();
|
||
states.set(b"On".to_vec(), appearance(&mut doc, "XObject", "Im0", icon));
|
||
let check = annot(&mut doc, "Widget", Object::Dict(states));
|
||
let comment_ap = appearance(&mut doc, "Font", "F2", comment_font);
|
||
let comment = annot(&mut doc, "FreeText", comment_ap);
|
||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![text.clone(), check.clone(), comment]))).unwrap();
|
||
let mut fonts = Dict::new();
|
||
fonts.set(b"F1".to_vec(), Object::Ref(field_font));
|
||
let mut dr = Dict::new();
|
||
dr.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
let mut form = Dict::new();
|
||
form.set(b"Fields".to_vec(), Object::Array(vec![text, check]));
|
||
form.set(b"DR".to_vec(), Object::Dict(dr));
|
||
let root = doc.root().unwrap();
|
||
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
|
||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||
let audit = audit_space(&doc, bytes.len() as u64);
|
||
let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().bytes;
|
||
assert!(get(SpaceCategory::Fonts) > 80_000, "both fonts are fonts: {audit:?}");
|
||
assert!(get(SpaceCategory::Images) > 2_000, "the check box's image is an image: {audit:?}");
|
||
assert!(get(SpaceCategory::Forms) < 2_000 && get(SpaceCategory::Comments) < 1_000, "{audit:?}");
|
||
assert!(get(SpaceCategory::Forms) > 0 && get(SpaceCategory::Comments) > 0, "the fields and the comment are still counted: {audit:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn space_audit_survives_deeply_nested_forms() {
|
||
// Each form draws the next; sorting their resources by kind recursed once per form, so a
|
||
// long enough chain overflowed the stack.
|
||
let mut doc = Document::new_empty();
|
||
let mut next: Option<ObjRef> = None;
|
||
for _ in 0..20_000 {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
if let Some(n) = next {
|
||
let mut x = Dict::new();
|
||
x.set(b"X".to_vec(), Object::Ref(n));
|
||
let mut res = Dict::new();
|
||
res.set(b"XObject".to_vec(), Object::Dict(x));
|
||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||
}
|
||
next = Some(doc.add(Object::Stream(Stream::from_raw(d, b"/X Do".to_vec()))));
|
||
}
|
||
page(&mut doc, &[("X", next.unwrap())], "/X Do");
|
||
let audit = audit_space(&doc, 0);
|
||
let forms = audit.iter().find(|u| u.category == SpaceCategory::XObjectForms).unwrap();
|
||
assert!(forms.bytes > 20_000 * 40, "every form is counted, however deep: {audit:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_links_and_unreferenced_destinations_go() {
|
||
let mut doc = Document::new_empty();
|
||
let p = page(&mut doc, &[], "BT ET");
|
||
let link = |doc: &mut Document, dest: Object| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||
d.set(b"Subtype".to_vec(), Object::name("Link"));
|
||
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||
d.set(b"Dest".to_vec(), dest);
|
||
Object::Ref(doc.add(Object::Dict(d)))
|
||
};
|
||
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||
let annots = vec![
|
||
link(&mut doc, Object::Array(vec![Object::Ref(p), Object::name("Fit")])),
|
||
link(&mut doc, Object::Array(vec![Object::Ref(ObjRef::new(999, 0)), Object::name("Fit")])),
|
||
link(&mut doc, s("gone")),
|
||
link(&mut doc, s("kept")),
|
||
];
|
||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap();
|
||
// Named destinations "kept" (used) and "unused"; a bookmark to a missing name.
|
||
let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]);
|
||
let mut tree = Dict::new();
|
||
tree.set(b"Names".to_vec(), Object::Array(vec![s("kept"), fit.clone(), s("unused"), fit]));
|
||
let tree = doc.add(Object::Dict(tree));
|
||
let mut names = Dict::new();
|
||
names.set(b"Dests".to_vec(), Object::Ref(tree));
|
||
let outlines = doc.add(Object::Null);
|
||
let mut item = Dict::new();
|
||
item.set(b"Title".to_vec(), s("Lost"));
|
||
item.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||
item.set(b"Dest".to_vec(), s("nowhere"));
|
||
let item = doc.add(Object::Dict(item));
|
||
let mut o = Dict::new();
|
||
o.set(b"First".to_vec(), Object::Ref(item));
|
||
o.set(b"Last".to_vec(), Object::Ref(item));
|
||
doc.set(outlines, Object::Dict(o));
|
||
let root = doc.root().unwrap();
|
||
doc.update_dict(root, |c| {
|
||
c.set(b"Names".to_vec(), Object::Dict(names));
|
||
c.set(b"Outlines".to_vec(), Object::Ref(outlines));
|
||
})
|
||
.unwrap();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (2, 1, 1));
|
||
let left = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len();
|
||
assert_eq!(left, 2);
|
||
assert!(doc.get(item).as_dict().unwrap().get(b"Dest").is_none(), "the bookmark stays, without its broken destination");
|
||
// Off: nothing changes.
|
||
let mut again = doc.clone();
|
||
let off = Settings { remove_invalid_links: false, remove_unreferenced_dests: false, ..Settings::default() };
|
||
assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn progress_is_reported_per_image_and_a_refusal_cancels() {
|
||
use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress};
|
||
let build = || {
|
||
let mut doc = Document::new_empty();
|
||
let a = image(&mut doc, 300, 300, 3, false, None);
|
||
let b = image(&mut doc, 300, 300, 1, false, None);
|
||
let c = image(&mut doc, 300, 300, 3, true, None);
|
||
page(&mut doc, &[("A", a), ("B", b), ("C", c)], "q 72 0 0 72 0 0 cm /A Do Q q 72 0 0 72 100 0 cm /B Do Q q 72 0 0 72 200 0 cm /C Do Q");
|
||
doc
|
||
};
|
||
let mut seen = Vec::new();
|
||
let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| {
|
||
seen.push(s);
|
||
true
|
||
})
|
||
.unwrap();
|
||
assert_eq!(report.images, 3);
|
||
assert_eq!(
|
||
seen,
|
||
[
|
||
Stage::Images { done: 0, total: 3 },
|
||
Stage::Images { done: 1, total: 3 },
|
||
Stage::Images { done: 2, total: 3 },
|
||
Stage::Images { done: 3, total: 3 },
|
||
Stage::CleanUp
|
||
]
|
||
);
|
||
|
||
// Stopping after the first image.
|
||
let mut calls = 0;
|
||
let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| {
|
||
calls += 1;
|
||
calls < 2
|
||
});
|
||
assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}");
|
||
assert_eq!(calls, 2, "nothing runs after the refusal");
|
||
}
|
||
|
||
/// A page with `resources` and, if given, `contents`.
|
||
fn raw_page(doc: &mut Document, resources: Object, contents: Option<ObjRef>) -> ObjRef {
|
||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
|
||
p.set(b"Resources".to_vec(), resources);
|
||
if let Some(c) = contents {
|
||
p.set(b"Contents".to_vec(), Object::Ref(c));
|
||
}
|
||
let r = doc.add(Object::Dict(p));
|
||
doc.update_dict(pages, |d| {
|
||
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||
kids.push(Object::Ref(r));
|
||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||
})
|
||
.unwrap();
|
||
r
|
||
}
|
||
|
||
/// Builders for the tests below.
|
||
fn xobjects(pairs: &[(&str, ObjRef)]) -> Dict {
|
||
let mut x = Dict::new();
|
||
for (n, r) in pairs {
|
||
x.set(n.as_bytes().to_vec(), Object::Ref(*r));
|
||
}
|
||
x
|
||
}
|
||
|
||
fn resources(x: Dict) -> Dict {
|
||
let mut res = Dict::new();
|
||
res.set(b"XObject".to_vec(), Object::Dict(x));
|
||
res
|
||
}
|
||
|
||
fn content(doc: &mut Document, c: &str) -> ObjRef {
|
||
doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes())))
|
||
}
|
||
|
||
fn form(doc: &mut Document, resources: Option<Object>, c: &str) -> ObjRef {
|
||
let mut d = Dict::new();
|
||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
|
||
if let Some(r) = resources {
|
||
d.set(b"Resources".to_vec(), r);
|
||
}
|
||
doc.add(Object::Stream(Stream::flate(d, c.as_bytes())))
|
||
}
|
||
|
||
fn xobject_names(doc: &Document, resources: &Object) -> Vec<String> {
|
||
let res = doc.resolve(resources);
|
||
let mut n: Vec<String> =
|
||
res.as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect();
|
||
n.sort();
|
||
n
|
||
}
|
||
|
||
fn page_xobjects(doc: &Document, page: ObjRef) -> Vec<String> {
|
||
let res = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().clone();
|
||
xobject_names(doc, &res)
|
||
}
|
||
|
||
fn count_images(bytes: &[u8]) -> usize {
|
||
let doc = Document::open(Arc::new(bytes.to_vec())).unwrap();
|
||
doc.object_numbers()
|
||
.into_iter()
|
||
.filter(|n| matches!(&*doc.get(ObjRef::new(*n, doc.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
|
||
.count()
|
||
}
|
||
|
||
#[test]
|
||
fn images_and_forms_nothing_draws_are_dropped() {
|
||
let mut doc = Document::new_empty();
|
||
let img = |doc: &mut Document| image(doc, 64, 64, 3, false, None);
|
||
|
||
// Edit a PDF deleting an image: the page gets a new content stream without the `Do`; the
|
||
// old one, which still names the image, is no longer used.
|
||
let t1 = img(&mut doc);
|
||
let old = content(&mut doc, "q 72 0 0 72 0 0 cm /ImT Do Q");
|
||
let t = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImT", t1)]))), Some(old));
|
||
let new = content(&mut doc, "");
|
||
doc.update_dict(t, |p| p.set(b"Contents".to_vec(), Object::Ref(new))).unwrap();
|
||
// Dropped: a JPEG a page no longer draws, beside one it does.
|
||
let a1 = img(&mut doc);
|
||
let a2 = image(&mut doc, 64, 64, 3, true, None);
|
||
let c = content(&mut doc, "q 72 0 0 72 0 0 cm /Im1 Do Q");
|
||
let a = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", a1), ("Im2", a2)]))), Some(c));
|
||
// Dropped from resources the page alone refers to, written as an object of their own.
|
||
let s1 = img(&mut doc);
|
||
let own = doc.add(Object::Dict(resources(xobjects(&[("Unused", s1)]))));
|
||
let c = content(&mut doc, "");
|
||
let s = raw_page(&mut doc, Object::Ref(own), Some(c));
|
||
// An image drawn only inside a form stays; one beside it goes.
|
||
let (d3, d4) = (img(&mut doc), img(&mut doc));
|
||
let f = form(&mut doc, None, "/Im3 Do");
|
||
let c = content(&mut doc, "/F Do");
|
||
let d = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("F", f), ("Im3", d3), ("Im4", d4)]))), Some(c));
|
||
|
||
// Kept, though the page's own content doesn't name them:
|
||
// - drawn by a form with resources of its own that lack the name (renderers fall back to
|
||
// the page's);
|
||
let k1 = img(&mut doc);
|
||
let mut procset = Dict::new();
|
||
procset.set(b"ProcSet".to_vec(), Object::Array(vec![Object::name("PDF")]));
|
||
let fk = form(&mut doc, Some(Object::Dict(procset)), "/ImK Do");
|
||
let c = content(&mut doc, "/Fk Do");
|
||
let k = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Fk", fk), ("ImK", k1)]))), Some(c));
|
||
// - drawn by a Type 3 glyph named like an inert key;
|
||
let g6 = img(&mut doc);
|
||
let glyph = content(&mut doc, "/Im6 Do");
|
||
let mut procs = Dict::new();
|
||
procs.set(b"P".to_vec(), Object::Ref(glyph));
|
||
let mut t3 = Dict::new();
|
||
t3.set(b"Type".to_vec(), Object::name("Font"));
|
||
t3.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||
t3.set(b"CharProcs".to_vec(), Object::Dict(procs));
|
||
let mut fonts = Dict::new();
|
||
fonts.set(b"T3".to_vec(), Object::Dict(t3));
|
||
let mut res = resources(xobjects(&[("Im6", g6)]));
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
let c = content(&mut doc, "BT /T3 1 Tf (P) Tj ET");
|
||
let g = raw_page(&mut doc, Object::Dict(res), Some(c));
|
||
// - after an inline image whose data runs into the operators (no space before `EI`), or
|
||
// given as the second of two operands;
|
||
let (l1, l2) = (img(&mut doc), img(&mut doc));
|
||
let c = content(&mut doc, "BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 00>EI Q q /ImL Do Q /Other /ImL2 Do");
|
||
let l = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImL", l1), ("ImL2", l2)]))), Some(c));
|
||
// - drawn by a form field that is on no page;
|
||
let z1 = img(&mut doc);
|
||
let c = content(&mut doc, "");
|
||
let z = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Z1", z1)]))), Some(c));
|
||
let wap = form(&mut doc, None, "/Z1 Do");
|
||
let mut appearance = Dict::new();
|
||
appearance.set(b"N".to_vec(), Object::Ref(wap));
|
||
let mut widget = Dict::new();
|
||
widget.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||
widget.set(b"AP".to_vec(), Object::Dict(appearance));
|
||
let widget = doc.add(Object::Dict(widget));
|
||
let mut acroform = Dict::new();
|
||
acroform.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(widget)]));
|
||
let root = doc.root().unwrap();
|
||
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acroform))).unwrap();
|
||
// - in resources shared by pages (or with an annotation's appearance), or inherited from the
|
||
// page tree: left alone;
|
||
let (b1, b2) = (img(&mut doc), img(&mut doc));
|
||
let shared = doc.add(Object::Dict(resources(xobjects(&[("X1", b1), ("X2", b2)]))));
|
||
let c = content(&mut doc, "0 0 m");
|
||
raw_page(&mut doc, Object::Ref(shared), Some(c));
|
||
let c = content(&mut doc, "/X1 Do");
|
||
raw_page(&mut doc, Object::Ref(shared), Some(c));
|
||
let (inh, unused_inh) = (img(&mut doc), img(&mut doc));
|
||
let tree = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
|
||
doc.update_dict(tree, |d| d.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Inh", inh), ("Old", unused_inh)]))))).unwrap();
|
||
// - named by another page for its own image.
|
||
let (r1, r2) = (img(&mut doc), img(&mut doc));
|
||
let c = content(&mut doc, "");
|
||
let r = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r1)]))), Some(c));
|
||
let c = content(&mut doc, "/Im9 Do");
|
||
raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r2)]))), Some(c));
|
||
|
||
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.unused_xobjects, 4, "{report:?}");
|
||
assert!(page_xobjects(&doc, t).is_empty(), "the deleted image goes");
|
||
assert_eq!(page_xobjects(&doc, a), ["Im1"]);
|
||
assert!(xobject_names(&doc, &Object::Ref(own)).is_empty());
|
||
assert!(page_xobjects(&doc, s).is_empty());
|
||
assert_eq!(page_xobjects(&doc, d), ["F", "Im3"]);
|
||
assert_eq!(page_xobjects(&doc, k), ["Fk", "ImK"]);
|
||
assert_eq!(page_xobjects(&doc, g), ["Im6"]);
|
||
assert_eq!(page_xobjects(&doc, l), ["ImL", "ImL2"]);
|
||
assert_eq!(page_xobjects(&doc, z), ["Z1"]);
|
||
assert_eq!(xobject_names(&doc, &Object::Ref(shared)), ["X1", "X2"]);
|
||
assert_eq!(page_xobjects(&doc, tree), ["Inh", "Old"]);
|
||
assert_eq!(page_xobjects(&doc, r), ["Im9"]);
|
||
let after = write_full(&doc, &SaveOptions::default()).unwrap();
|
||
assert_eq!((count_images(&before), count_images(&after)), (17, 13), "the dropped images are gone from the file");
|
||
}
|
||
|
||
#[test]
|
||
fn nothing_is_dropped_when_a_stream_cannot_be_checked() {
|
||
// A page with an image nothing draws, and an appearance stream on it.
|
||
let build = |stream: Stream| {
|
||
let mut doc = Document::new_empty();
|
||
let unused = image(&mut doc, 64, 64, 3, false, None);
|
||
let c = content(&mut doc, "");
|
||
let page = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", unused)]))), Some(c));
|
||
let other = doc.add(Object::Stream(stream));
|
||
let mut appearance = Dict::new();
|
||
appearance.set(b"N".to_vec(), Object::Ref(other));
|
||
let mut annot = Dict::new();
|
||
annot.set(b"AP".to_vec(), Object::Dict(appearance));
|
||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annot)]))).unwrap();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
(report.unused_xobjects, page_xobjects(&doc, page))
|
||
};
|
||
let (gone, kept) = ((1, Vec::<String>::new()), (0, vec!["Im1".to_string()]));
|
||
// Valid compressed data with extra keys.
|
||
let flate = |extra: &[(&str, Object)]| {
|
||
let mut s = Stream::flate(Dict::new(), b"0 0 m");
|
||
s.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||
for (k, v) in extra {
|
||
s.dict.set(k.as_bytes().to_vec(), v.clone());
|
||
}
|
||
s
|
||
};
|
||
let parms = |k: &str, v: Object| {
|
||
let mut p = Dict::new();
|
||
p.set(k.as_bytes().to_vec(), v);
|
||
Object::Dict(p)
|
||
};
|
||
// Readable: the image goes.
|
||
assert_eq!(build(flate(&[])), gone);
|
||
assert_eq!(build(flate(&[("DecodeParms", parms("Predictor", Object::Int(1)))])), gone);
|
||
// The name written with an escape still counts.
|
||
assert_eq!(build(Stream::flate(Dict::new(), b"/Im#31 Do")), kept);
|
||
// Read differently by viewers, or not at all: nothing goes.
|
||
let dangling = Object::Ref(ObjRef::new(999, 0));
|
||
let cases = [
|
||
Stream::from_raw(flate(&[]).dict, b"not zlib data".to_vec()),
|
||
flate(&[("Filter", dangling.clone())]),
|
||
flate(&[("Filter", Object::Array(vec![Object::name("FlateDecode"), dangling.clone()]))]),
|
||
flate(&[("Filter", Object::Array(vec![Object::name("Crypt"), Object::name("FlateDecode")]))]),
|
||
flate(&[("DP", parms("Predictor", Object::Int(1)))]),
|
||
flate(&[("DecodeParms", Object::Array(vec![Object::Dict(Dict::new()), Object::Dict(Dict::new())]))]),
|
||
flate(&[("DecodeParms", parms("Predictor", dangling))]),
|
||
flate(&[("DecodeParms", parms("Predictor", Object::Real(12.5)))]),
|
||
flate(&[("F", Object::name("external.dat"))]),
|
||
// An image codec on a stream drawn as an appearance: viewers decode it as content.
|
||
Stream::from_raw(
|
||
{
|
||
let mut d = Dict::new();
|
||
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||
d
|
||
},
|
||
vec![0; 8],
|
||
),
|
||
];
|
||
for s in cases {
|
||
let dict = s.dict.clone();
|
||
assert_eq!(build(s), kept, "{dict:?}");
|
||
}
|
||
}
|
||
|
||
/// `data` as ASCII85 text with the `~>` end marker, as ASCII85Decode reads it.
|
||
fn ascii85(data: &[u8]) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
for chunk in data.chunks(4) {
|
||
let mut word = [0u8; 4];
|
||
word[..chunk.len()].copy_from_slice(chunk);
|
||
let mut v = u32::from_be_bytes(word);
|
||
let mut digits = [0u8; 5];
|
||
for d in digits.iter_mut().rev() {
|
||
*d = (v % 85) as u8 + b'!';
|
||
v /= 85;
|
||
}
|
||
out.extend_from_slice(&digits[..chunk.len() + 1]);
|
||
}
|
||
out.extend_from_slice(b"~>");
|
||
out
|
||
}
|
||
|
||
/// `data` as hex digits with the `>` end marker, as ASCIIHexDecode reads it.
|
||
fn ascii_hex(data: &[u8]) -> Vec<u8> {
|
||
let mut out: Vec<u8> = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||
out.push(b'>');
|
||
out
|
||
}
|
||
|
||
fn filtered(names: &[&str], raw: Vec<u8>) -> Stream {
|
||
let mut d = Dict::new();
|
||
d.set(b"Filter".to_vec(), Object::Array(names.iter().map(|n| Object::name(n)).collect()));
|
||
Stream::from_raw(d, raw)
|
||
}
|
||
|
||
/// A one-page document whose page content is `content`, with the content's reference.
|
||
fn with_content(content: Stream) -> (Document, ObjRef) {
|
||
let mut doc = Document::new_empty();
|
||
let pg = page(&mut doc, &[], "");
|
||
let c = doc.add(Object::Stream(content));
|
||
doc.update_dict(pg, |d| d.set(b"Contents".to_vec(), Object::Ref(c))).unwrap();
|
||
(doc, c)
|
||
}
|
||
|
||
fn vector_ops() -> Vec<u8> {
|
||
"0 0 m 612 792 l S\n".repeat(400).into_bytes()
|
||
}
|
||
|
||
#[test]
|
||
fn ascii_wrappers_come_off_filtered_streams_and_the_inner_data_is_unchanged() {
|
||
let ops = vector_ops();
|
||
let inner = Stream::flate(Dict::new(), &ops).raw.to_vec();
|
||
for (outer, encoded) in [("ASCII85Decode", ascii85(&inner)), ("ASCIIHexDecode", ascii_hex(&inner))] {
|
||
let source = filtered(&[outer, "FlateDecode"], encoded);
|
||
let (mut doc, c) = with_content(source.clone());
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.streams_compressed, 1, "{outer}");
|
||
let after = stream(&doc, c);
|
||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode")])), "{outer}");
|
||
assert_eq!(after.raw.to_vec(), inner, "{outer}: the inner Flate bytes are kept exactly");
|
||
assert_eq!(after.decoded_strict().unwrap(), ops, "{outer}");
|
||
assert!(after.raw.len() < source.raw.len(), "{outer}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn streams_the_wrapper_stripping_does_not_touch_stay_unchanged() {
|
||
let inner = Stream::flate(Dict::new(), &vector_ops()).raw.to_vec();
|
||
let wrapped = ascii85(&inner);
|
||
let mut with_parms = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||
with_parms.dict.set(b"DecodeParms".to_vec(), Object::Dict(Dict::new()));
|
||
let mut image = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||
image.dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
let mut objstm = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||
objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm"));
|
||
let mut metadata = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||
metadata.dict.set(b"Type".to_vec(), Object::name("Metadata"));
|
||
let cases = [
|
||
("malformed ASCII85", filtered(&["ASCII85Decode", "FlateDecode"], b"{{ not ASCII85 ~>".to_vec())),
|
||
("malformed ASCIIHex", filtered(&["ASCIIHexDecode", "FlateDecode"], b"G0>".to_vec())),
|
||
("DecodeParms", with_parms),
|
||
("single ASCII filter", filtered(&["ASCII85Decode"], wrapped.clone())),
|
||
("image", image),
|
||
("object stream", objstm),
|
||
("metadata", metadata),
|
||
("crypt after the wrapper", filtered(&["ASCII85Decode", "Crypt", "FlateDecode"], wrapped.clone())),
|
||
("tiny", filtered(&["ASCII85Decode", "FlateDecode"], ascii85(b"ab"))),
|
||
("single Flate", Stream::flate(Dict::new(), &vector_ops())),
|
||
];
|
||
for (label, s) in cases {
|
||
let (mut doc, c) = with_content(s.clone());
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.streams_compressed, 0, "{label}");
|
||
assert_eq!(stream(&doc, c), s, "{label}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_second_run_leaves_the_stripped_stream_alone() {
|
||
let inner = Stream::flate(Dict::new(), &vector_ops()).raw.to_vec();
|
||
let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "FlateDecode"], ascii85(&inner)));
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
let once = stream(&doc, c);
|
||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(r.streams_compressed, 0);
|
||
assert_eq!(stream(&doc, c), once);
|
||
}
|
||
|
||
/// A grey image `w` px wide and one row high, every pixel `v`; JPEG or Flate.
|
||
fn strip(doc: &mut Document, w: u32, v: u8, jpeg: bool) -> ObjRef {
|
||
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 as i64));
|
||
d.set(b"Height".to_vec(), Object::Int(1));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let px = vec![v; w as usize];
|
||
let s = if jpeg {
|
||
let mut out = Vec::new();
|
||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode(&px, w, 1, image::ExtendedColorType::L8).unwrap();
|
||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||
Stream::from_raw(d, out)
|
||
} else {
|
||
Stream::flate(d, &px)
|
||
};
|
||
doc.add(Object::Stream(s))
|
||
}
|
||
|
||
/// A page drawn the way some PDF printers write a page picture: `rows` images one pixel row
|
||
/// high, 1000 units wide, scaled into a 24 pt square (so 600 ppi across at 200 px). Row `r`
|
||
/// from the top is grey `r`; `edit` changes the content of each row.
|
||
fn printed_page(rows: usize, jpeg: impl Fn(usize) -> bool, edit: impl Fn(usize, String) -> String) -> (Document, Vec<ObjRef>) {
|
||
let mut doc = Document::new_empty();
|
||
let mut names = Vec::new();
|
||
let mut refs = Vec::new();
|
||
let mut content = String::from("q 0.024 0 0 0.024 0 0 cm\n");
|
||
for r in 0..rows {
|
||
let img = strip(&mut doc, 200, r as u8, jpeg(r));
|
||
refs.push(img);
|
||
let y = (rows - 1 - r) as f64;
|
||
content += &edit(r, format!("q 1000 0 0 1 0 {y} cm\n/R{r} Do\nQ\n"));
|
||
names.push(format!("R{r}"));
|
||
}
|
||
content += "Q\n";
|
||
let xo: Vec<(&str, ObjRef)> = names.iter().map(String::as_str).zip(refs.iter().copied()).collect();
|
||
let p = page(&mut doc, &xo, &content);
|
||
// No thumbnail: it would be the old content stream, which still names the strips.
|
||
doc.update_dict(p, |d| {
|
||
d.remove(b"Thumb");
|
||
})
|
||
.unwrap();
|
||
(doc, refs)
|
||
}
|
||
|
||
/// The images the first page's content stream draws, by name, with their streams.
|
||
fn drawn_images(doc: &Document) -> Vec<Stream> {
|
||
let pages = pdfcraft_annot::page_refs(doc).unwrap();
|
||
let p = doc.get(pages[0]).as_dict().cloned().unwrap();
|
||
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||
let c = match &*doc.resolve(p.get(b"Contents").unwrap()) {
|
||
Object::Stream(s) => s.decoded().unwrap(),
|
||
_ => panic!("contents"),
|
||
};
|
||
pdfcraft_content::parse(&c)
|
||
.ops
|
||
.iter()
|
||
.filter(|o| o.is("Do"))
|
||
.map(|o| match &*doc.resolve(xo.get(o.name(0).unwrap()).unwrap()) {
|
||
Object::Stream(s) => s.clone(),
|
||
_ => panic!("image"),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() {
|
||
// 120 strips, every other one JPEG and half as wide (as one PDF printer writes them).
|
||
let (mut doc, _) = printed_page(120, |r| r % 2 == 1, |_, s| s);
|
||
let keep = Settings {
|
||
gray: ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain },
|
||
..Settings::default()
|
||
};
|
||
let r = optimize(&mut doc, &keep).unwrap();
|
||
assert_eq!((r.merged_images, r.fragments_merged), (1, 120), "{r:?}");
|
||
let drawn = drawn_images(&doc);
|
||
assert_eq!(drawn.len(), 1, "one image instead of 120");
|
||
let img = &drawn[0];
|
||
assert_eq!((img.dict.int(b"Width"), img.dict.int(b"Height")), (Some(200), Some(120)), "the finest resolution both ways");
|
||
let px = img.decoded().unwrap();
|
||
// Each row is its strip's grey, top row first; JPEG rows within a few levels.
|
||
for (row, chunk) in px.chunks(200).enumerate() {
|
||
assert!(chunk.iter().all(|&v| (v as i32 - row as i32).abs() <= 3), "row {row}: {:?}", &chunk[..4]);
|
||
}
|
||
assert_eq!(r.unused_xobjects, 120, "the strips are dropped");
|
||
}
|
||
|
||
#[test]
|
||
fn the_mobile_preset_makes_a_printed_page_small() {
|
||
use pdfcraft_optimize::Preset;
|
||
let (doc, _) = printed_page(200, |_| true, |_, s| s);
|
||
let bytes = |d: &Document| write_full(d, &SaveOptions::default()).unwrap().len();
|
||
let before = bytes(&doc);
|
||
let mut mobile = doc.clone();
|
||
let r = optimize(&mut mobile, &Preset::Mobile.settings()).unwrap();
|
||
assert_eq!((r.merged_images, r.images_resampled), (1, 1), "{r:?}");
|
||
let img = &drawn_images(&mobile)[0];
|
||
// 24 pt = 1/3 in at 96 ppi.
|
||
assert_eq!((img.dict.int(b"Width"), img.dict.int(b"Height")), (Some(32), Some(32)));
|
||
assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..]));
|
||
assert!(bytes(&mobile) * 10 < before, "{} → {}", before, bytes(&mobile));
|
||
assert_eq!(Preset::of(&Preset::Mobile.settings()), Some(Preset::Mobile));
|
||
assert_eq!(Preset::from_name("mobile"), Some(Preset::Mobile));
|
||
}
|
||
|
||
#[test]
|
||
fn images_that_do_not_tile_a_picture_are_left_alone() {
|
||
let unmerged = |doc: &mut Document, settings: &Settings| {
|
||
let r = optimize(doc, settings).unwrap();
|
||
assert_eq!(r.merged_images, 0, "{r:?}");
|
||
};
|
||
let all = Settings::default();
|
||
// A gap between two rows.
|
||
let (mut gap, _) =
|
||
printed_page(40, |_| false, |r, s| if r < 20 { s.replace(&format!(" 0 {} cm", 39 - r), &format!(" 0 {} cm", 41 - r)) } else { s });
|
||
let r = optimize(&mut gap, &all).unwrap();
|
||
assert_eq!((r.merged_images, r.fragments_merged), (2, 40), "one picture each side of the gap: {r:?}");
|
||
// Rotated rows.
|
||
let (mut turned, _) = printed_page(40, |_| false, |_, s| s.replace("q 1000 0 0 1", "q 0 1 -1000 0"));
|
||
unmerged(&mut turned, &all);
|
||
// Something else drawn between the rows.
|
||
let (mut between, _) = printed_page(40, |_| false, |r, s| if r == 10 { format!("0 0 10 10 re f\n{s}") } else { s });
|
||
let r = optimize(&mut between, &all).unwrap();
|
||
assert_eq!((r.merged_images, r.fragments_merged), (2, 40), "two runs, each side of the fill: {r:?}");
|
||
// Too few rows, and the option off.
|
||
let (mut few, _) = printed_page(3, |_| false, |_, s| s);
|
||
unmerged(&mut few, &all);
|
||
let (mut off, _) = printed_page(40, |_| false, |_, s| s);
|
||
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
|
||
}
|
||
|
||
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
|
||
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
|
||
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");
|
||
for _ in 1..levels {
|
||
inner = form(doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||
}
|
||
inner
|
||
}
|
||
|
||
#[test]
|
||
fn shared_forms_are_measured_in_each_pages_physical_units() {
|
||
for reverse in [false, true] {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 720, 720, 3, false, None);
|
||
let outer = nested_forms(&mut doc, img, 3);
|
||
let normal = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q");
|
||
let scaled = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q");
|
||
doc.update_dict(scaled, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap();
|
||
let pages = if reverse { [scaled, normal] } else { [normal, scaled] };
|
||
assert_eq!(effective_resolutions(&doc, &pages)[&img], 72.0, "reverse={reverse}");
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(720));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_deeply_nested_form_is_measured_at_its_largest_use() {
|
||
// 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled.
|
||
let small = "q 72 0 0 72 36 36 cm /F Do Q";
|
||
let large = "q 576 0 0 576 18 100 cm /F Do Q";
|
||
for levels in [1, 6, 12] {
|
||
for (pages, label) in [(vec![format!("{small} {large}")], "one page"), (vec![small.into(), large.into()], "two pages")] {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let outer = nested_forms(&mut doc, img, levels);
|
||
for c in &pages {
|
||
page(&mut doc, &[("F", outer)], c);
|
||
}
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 150.0, "{levels} levels, {label}");
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200), "{levels} levels, {label}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_form_drawing_itself_is_walked_once_per_use() {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let f = form(&mut doc, None, "/Im Do q 2 0 0 2 0 0 cm /F Do Q");
|
||
let mut x = xobjects(&[("Im", img)]);
|
||
x.set(b"F".to_vec(), Object::Ref(f));
|
||
let mut d = match &*doc.get(f) {
|
||
Object::Stream(s) => s.clone(),
|
||
_ => unreachable!(),
|
||
};
|
||
d.dict.set(b"Resources".to_vec(), Object::Dict(resources(x)));
|
||
doc.set(f, Object::Stream(d));
|
||
page(&mut doc, &[("F", f)], "q 72 0 0 72 36 36 cm /F Do Q");
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
// Only the outer use: the cycle isn't followed, and doesn't count as a missed use.
|
||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 1200.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_shared_form_without_resources_draws_each_callers_image() {
|
||
// G has no /Resources, so its /Im is whichever image the form drawing it names.
|
||
let mut doc = Document::new_empty();
|
||
let a = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let b = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let g = form(&mut doc, None, "/Im Do");
|
||
let mut outers = Vec::new();
|
||
for img in [a, b] {
|
||
let mut inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("Im", img), ("G", g)])))), "/G Do");
|
||
for _ in 1..6 {
|
||
inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||
}
|
||
outers.push(inner);
|
||
}
|
||
page(&mut doc, &[("A", outers[0]), ("B", outers[1])], "q 72 0 0 72 36 36 cm /A Do Q q 576 0 0 576 18 100 cm /B Do Q");
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
let ppi = effective_resolutions(&doc, &refs);
|
||
assert_eq!(ppi[&a].round(), 1200.0);
|
||
assert_eq!(ppi.get(&b).map(|p| p.round()), Some(150.0));
|
||
}
|
||
|
||
#[test]
|
||
fn images_are_not_resampled_when_some_uses_could_not_be_measured() {
|
||
// Too deep: the 13th form isn't followed, so its use of the image is unknown.
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let other = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let deep = nested_forms(&mut doc, img, 13);
|
||
page(
|
||
&mut doc,
|
||
&[("F", deep), ("Im", img), ("O", other)],
|
||
"q 72 0 0 72 36 36 cm /Im Do Q q 72 0 0 72 200 36 cm /O Do Q q 576 0 0 576 18 100 cm /F Do Q",
|
||
);
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
let ppi = effective_resolutions(&doc, &refs);
|
||
assert_eq!((ppi[&img], ppi[&other]), (f64::INFINITY, f64::INFINITY));
|
||
optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200));
|
||
|
||
// Too much work: forms without resources (so nothing can be skipped), each drawing the next
|
||
// three times, twelve deep, would be (3^12 - 1) / 2 walks.
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let mut names = vec![("Im", img)];
|
||
let mut next = "Im";
|
||
let labels = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"];
|
||
for (i, label) in labels.iter().enumerate().rev() {
|
||
let s = 1.0 + i as f64 / 100.0;
|
||
let f = form(&mut doc, None, &format!("q {s} 0 0 {s} 0 0 cm /{next} Do Q /{next} Do q 1 0 0 1 1 0 cm /{next} Do Q"));
|
||
names.push((label, f));
|
||
next = label;
|
||
}
|
||
page(&mut doc, &names, "q 72 0 0 72 36 36 cm /F1 Do Q");
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||
}
|
||
|
||
/// Each form entry counts against the page's form allowance, even for a form that decodes to
|
||
/// nothing and so costs no decoded bytes.
|
||
#[test]
|
||
fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() {
|
||
let mut doc = Document::new_empty();
|
||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||
let empty = form(&mut doc, None, "");
|
||
let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1);
|
||
page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}"));
|
||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||
}
|
||
|
||
#[test]
|
||
fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() {
|
||
let mut base = Document::new_empty();
|
||
page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET");
|
||
let root = base.root().unwrap();
|
||
base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap();
|
||
|
||
for (compat, expected) in [
|
||
(Compatibility::Pdf1_4, "1.4"),
|
||
(Compatibility::Pdf1_5, "1.5"),
|
||
(Compatibility::Pdf1_6, "1.6"),
|
||
(Compatibility::Pdf1_7, "1.7"),
|
||
(Compatibility::Pdf2_0, "2.0"),
|
||
] {
|
||
let mut doc = base.clone();
|
||
let settings = Settings { compatibility: compat, ..Settings::default() };
|
||
optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!(doc.version(), expected);
|
||
assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared");
|
||
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
|
||
assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}");
|
||
let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm");
|
||
assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}");
|
||
let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap();
|
||
assert_eq!(info.pdf_version, expected);
|
||
}
|
||
|
||
// Disabling object_streams on PDF 1.7 writes a classic cross-reference table.
|
||
let mut doc = base.clone();
|
||
let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() };
|
||
optimize(&mut doc, &settings).unwrap();
|
||
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
|
||
assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm"));
|
||
|
||
// Encryption that requires a newer PDF version than the chosen compatibility is refused.
|
||
let mut aes256 = base.clone();
|
||
aes256
|
||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||
user_password: "u",
|
||
owner_password: "o",
|
||
permissions: -1,
|
||
encrypt_metadata: true,
|
||
seed: [1; 32],
|
||
})
|
||
.unwrap();
|
||
assert!(matches!(
|
||
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }),
|
||
Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" })
|
||
));
|
||
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap();
|
||
assert_eq!(aes256.version(), "2.0");
|
||
|
||
let mut aes128 = base;
|
||
aes128
|
||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||
algorithm: pdfcraft_cos::Algorithm::Aes128,
|
||
user_password: "u",
|
||
owner_password: "o",
|
||
permissions: -1,
|
||
encrypt_metadata: true,
|
||
seed: [1; 32],
|
||
})
|
||
.unwrap();
|
||
assert!(matches!(
|
||
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }),
|
||
Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" })
|
||
));
|
||
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn image_resolution_keeps_comments_across_content_pieces() {
|
||
for ending in ["", "\n", "\r", "\r\n"] {
|
||
let mut doc = Document::new_empty();
|
||
let visible = image(&mut doc, 20, 20, 3, false, None);
|
||
let hidden = image(&mut doc, 20, 20, 3, false, None);
|
||
let page = page(&mut doc, &[("Visible", visible), ("Hidden", hidden)], "");
|
||
let first = doc
|
||
.add(Object::Stream(Stream::from_raw(Dict::new(), format!("q 72 0 0 72 0 0 cm /Visible Do Q\n% trailing comment{ending}").into_bytes())));
|
||
let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 144 0 0 cm /Hidden Do Q\n".to_vec())));
|
||
doc.update_dict(page, |dict| {
|
||
dict.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Int(17), Object::Ref(second)]));
|
||
})
|
||
.unwrap();
|
||
let measured = effective_resolutions(&doc, &[page]);
|
||
assert_eq!(measured.get(&visible), Some(&20.0));
|
||
if ending.is_empty() {
|
||
assert_eq!(measured.len(), 1);
|
||
assert_eq!(measured.get(&hidden), None, "a comment must hide the next piece's first line");
|
||
} else {
|
||
assert_eq!(measured.len(), 2);
|
||
assert_eq!(measured.get(&hidden), Some(&10.0), "a real line ending exposes the image");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() {
|
||
// /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the
|
||
// real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product.
|
||
let mut doc = Document::new_empty();
|
||
let side = i64::from(u32::MAX) + 101;
|
||
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(side));
|
||
d.set(b"Height".to_vec(), Object::Int(side));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||
let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256])));
|
||
page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q");
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||
assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was");
|
||
}
|
||
|
||
#[test]
|
||
fn a_hostile_soft_mask_leaves_its_image_unchanged() {
|
||
let mut doc = Document::new_empty();
|
||
let mask = image(&mut doc, 400, 400, 1, false, None);
|
||
let mut hostile = stream(&doc, mask);
|
||
hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401));
|
||
doc.set(mask, Object::Stream(hostile.clone()));
|
||
let photo = image(&mut doc, 400, 400, 3, false, Some(mask));
|
||
let before = stream(&doc, photo);
|
||
page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q");
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.images_resampled, 0, "{report:?}");
|
||
assert_eq!(stream(&doc, photo), before);
|
||
assert_eq!(stream(&doc, mask), hostile);
|
||
}
|
||
|
||
#[test]
|
||
fn indirectly_referenced_destination_is_kept() {
|
||
// A link's /Dest can be an indirect reference to the name string, not the string itself. Reduce
|
||
// File Size read the name only from a direct string, so it deleted a destination the link still
|
||
// used, silently breaking the link.
|
||
let mut doc = Document::new_empty();
|
||
let p = page(&mut doc, &[], "BT ET");
|
||
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||
// A link whose /Dest is `ref -> "target"`, and an unused destination for contrast.
|
||
let name_ref = Object::Ref(doc.add(s("target")));
|
||
let mut link = Dict::new();
|
||
link.set(b"Type".to_vec(), Object::name("Annot"));
|
||
link.set(b"Subtype".to_vec(), Object::name("Link"));
|
||
link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||
link.set(b"Dest".to_vec(), name_ref);
|
||
let link = Object::Ref(doc.add(Object::Dict(link)));
|
||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![link]))).unwrap();
|
||
let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]);
|
||
let mut tree = Dict::new();
|
||
tree.set(b"Names".to_vec(), Object::Array(vec![s("target"), fit.clone(), s("unused"), fit]));
|
||
let tree = doc.add(Object::Dict(tree));
|
||
let mut names = Dict::new();
|
||
names.set(b"Dests".to_vec(), Object::Ref(tree));
|
||
let root = doc.root().unwrap();
|
||
doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
// Only "unused" is removed; the indirectly-referenced "target" is kept.
|
||
assert_eq!(report.unreferenced_dests, 1);
|
||
let left = named_dest_keys(&doc);
|
||
assert!(left.contains(&b"target".to_vec()), "indirectly referenced destination was kept: {left:?}");
|
||
assert!(!left.contains(&b"unused".to_vec()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_destination_named_only_by_a_remote_or_embedded_go_to_is_removed() {
|
||
// The /D of a GoToR or GoToE action names a destination in another document. A local
|
||
// destination of the same name that nothing else uses is unreferenced.
|
||
let mut doc = Document::new_empty();
|
||
let p = page(&mut doc, &[], "BT ET");
|
||
let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||
let link = |doc: &mut Document, action: Dict| {
|
||
let mut link = Dict::new();
|
||
link.set(b"Type".to_vec(), Object::name("Annot"));
|
||
link.set(b"Subtype".to_vec(), Object::name("Link"));
|
||
link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||
link.set(b"A".to_vec(), Object::Dict(action));
|
||
Object::Ref(doc.add(Object::Dict(link)))
|
||
};
|
||
let mut go_to = Dict::new();
|
||
go_to.set(b"S".to_vec(), Object::name("GoTo"));
|
||
go_to.set(b"D".to_vec(), s("local"));
|
||
let mut remote = Dict::new();
|
||
remote.set(b"S".to_vec(), Object::name("GoToR"));
|
||
remote.set(b"F".to_vec(), s("other.pdf"));
|
||
remote.set(b"D".to_vec(), s("remote"));
|
||
let mut target = Dict::new();
|
||
target.set(b"R".to_vec(), Object::name("C"));
|
||
target.set(b"N".to_vec(), s("attached.pdf"));
|
||
let mut embedded = Dict::new();
|
||
embedded.set(b"S".to_vec(), Object::name("GoToE"));
|
||
embedded.set(b"T".to_vec(), Object::Dict(target));
|
||
embedded.set(b"D".to_vec(), s("embedded"));
|
||
let links = vec![link(&mut doc, go_to), link(&mut doc, remote), link(&mut doc, embedded)];
|
||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(links))).unwrap();
|
||
let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]);
|
||
let mut tree = Dict::new();
|
||
tree.set(b"Names".to_vec(), Object::Array(vec![s("embedded"), fit.clone(), s("local"), fit.clone(), s("remote"), fit]));
|
||
let tree = doc.add(Object::Dict(tree));
|
||
let mut names = Dict::new();
|
||
names.set(b"Dests".to_vec(), Object::Ref(tree));
|
||
let root = doc.root().unwrap();
|
||
doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap();
|
||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||
assert_eq!(report.unreferenced_dests, 2);
|
||
assert_eq!(named_dest_keys(&doc), vec![b"local".to_vec()]);
|
||
}
|
||
|
||
/// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it).
|
||
fn named_dest_keys(doc: &Document) -> Vec<Vec<u8>> {
|
||
let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||
let names = doc.resolve(cat.get(b"Names").unwrap()).as_dict().cloned().unwrap();
|
||
let tree = doc.resolve(names.get(b"Dests").unwrap()).as_dict().cloned().unwrap();
|
||
let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap();
|
||
arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect()
|
||
}
|
||
|
||
/// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters.
|
||
fn lzw_ops() -> Vec<u8> {
|
||
(0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect()
|
||
}
|
||
|
||
fn lzw(data: &[u8], params: &Params) -> Vec<u8> {
|
||
pdfcraft_filters::encode(&Filter::Lzw, params, data).unwrap()
|
||
}
|
||
|
||
/// `params` as a `/DecodeParms` dictionary, only the entries that differ from the defaults.
|
||
fn decode_parms(p: &Params) -> Dict {
|
||
let mut d = Dict::new();
|
||
for (k, v, default) in [
|
||
(&b"Predictor"[..], p.predictor, 1),
|
||
(b"Colors", p.colors, 1),
|
||
(b"BitsPerComponent", p.bits_per_component, 8),
|
||
(b"Columns", p.columns, 1),
|
||
(b"EarlyChange", p.early_change, 1),
|
||
] {
|
||
if v != default {
|
||
d.set(k.to_vec(), Object::Int(v));
|
||
}
|
||
}
|
||
d
|
||
}
|
||
|
||
/// A 64 × 48 RGB image, LZW-encoded with `params`.
|
||
fn lzw_image(doc: &mut Document, params: &Params) -> (ObjRef, Vec<u8>) {
|
||
let (w, h) = (64u32, 48u32);
|
||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 5) as u8, ((i % w + i / w) * 2) as u8]).collect();
|
||
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.into()));
|
||
d.set(b"Height".to_vec(), Object::Int(h.into()));
|
||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(params)));
|
||
(doc.add(Object::Stream(Stream::from_raw(d, lzw(&px, params)))), px)
|
||
}
|
||
|
||
/// Settings that leave images to the clean-up: no resampling, no recompression.
|
||
fn clean_up_only() -> Settings {
|
||
let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||
Settings { color: keep, gray: keep, ..Settings::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn lzw_streams_become_flate_and_decode_to_the_same_bytes() {
|
||
let ops = lzw_ops();
|
||
let early = Params::default();
|
||
let late = Params { early_change: 0, ..Params::default() };
|
||
let png = Params { predictor: 15, colors: 3, columns: 64, ..Params::default() };
|
||
let png_late = Params { early_change: 0, ..png.clone() };
|
||
let mut doc = Document::new_empty();
|
||
let content = |doc: &mut Document, p: &Params| {
|
||
let mut d = Dict::new();
|
||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||
if *p != Params::default() {
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(p)));
|
||
}
|
||
doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, p))))
|
||
};
|
||
let c_early = content(&mut doc, &early);
|
||
let c_late = content(&mut doc, &late);
|
||
let (img, px) = lzw_image(&mut doc, &png);
|
||
let (img_late, _) = lzw_image(&mut doc, &png_late);
|
||
page(&mut doc, &[("Im", img), ("Im2", img_late)], "q 64 0 0 48 72 600 cm /Im Do Q q 64 0 0 48 172 600 cm /Im2 Do Q");
|
||
let pg = pdfcraft_annot::page_refs(&doc).unwrap()[0];
|
||
doc.update_dict(pg, |d| {
|
||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(c_early), Object::Ref(c_late), d.get(b"Contents").unwrap().clone()]))
|
||
})
|
||
.unwrap();
|
||
// Each LZW stream reads as its own data before, so a decoder ignoring EarlyChange fails here.
|
||
assert_eq!((stream(&doc, c_early).decoded_strict().unwrap(), stream(&doc, c_late).decoded_strict().unwrap()), (ops.clone(), ops.clone()));
|
||
assert!(lzw(&ops, &early) != lzw(&ops, &late), "the two code widths differ on this data");
|
||
|
||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||
assert_eq!(r.lzw_streams, 4, "{r:?}");
|
||
for (label, at, data) in
|
||
[("content", c_early, &ops), ("content, EarlyChange 0", c_late, &ops), ("image", img, &px), ("image, EarlyChange 0", img_late, &px)]
|
||
{
|
||
let after = stream(&doc, at);
|
||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")), "{label}");
|
||
assert_eq!(&after.decoded_strict().unwrap(), data, "{label}");
|
||
let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict);
|
||
assert!(parms.is_none_or(|p| !p.contains(b"EarlyChange")), "{label}: EarlyChange is LZW's alone");
|
||
}
|
||
// The image keeps its PNG predictor, now applied under Flate.
|
||
let parms = stream(&doc, img).dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
|
||
assert_eq!((parms.int(b"Predictor"), parms.int(b"Colors"), parms.int(b"Columns")), (Some(15), Some(3), Some(64)));
|
||
assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream");
|
||
}
|
||
|
||
#[test]
|
||
fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() {
|
||
let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() };
|
||
let mut doc = Document::new_empty();
|
||
let (img, _) = lzw_image(&mut doc, &png);
|
||
let pg = page(&mut doc, &[("Im", img)], "");
|
||
let ops = b"0.2 0.5 0.8 rg 36 36 300 200 re f q 256 0 0 192 72 400 cm /Im Do Q\n".repeat(4);
|
||
let mut d = Dict::new();
|
||
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
|
||
d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(&Params { early_change: 0, ..Params::default() })));
|
||
let c = doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, &Params { early_change: 0, ..Params::default() }))));
|
||
doc.update_dict(pg, |p| p.set(b"Contents".to_vec(), Object::Ref(c))).unwrap();
|
||
let render = |doc: &Document| {
|
||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
out.rgba
|
||
};
|
||
let before = render(&doc);
|
||
// The picture and the rectangle are drawn: the page is not blank.
|
||
assert!(before.chunks(4).any(|p| p[..3] != [255, 255, 255]));
|
||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||
assert_eq!(r.lzw_streams, 2, "{r:?}");
|
||
assert_eq!(stream(&doc, c).dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
|
||
assert!(render(&doc) == before, "the page renders pixel for pixel as before");
|
||
}
|
||
|
||
#[test]
|
||
fn lzw_inside_a_filter_chain_is_replaced_and_the_other_stages_kept() {
|
||
let ops = lzw_ops();
|
||
let inner = lzw(&ops, &Params::default());
|
||
// An ASCII85 layer outside the LZW goes with it; a JPEG inside it stays encoded as it was.
|
||
let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&inner)));
|
||
let photo = image(&mut doc, 40, 30, 3, true, None);
|
||
let jpeg = stream(&doc, photo).raw.to_vec();
|
||
let mut picture = filtered(&["LZWDecode", "DCTDecode"], lzw(&jpeg, &Params::default()));
|
||
picture.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Null]));
|
||
let pic = doc.add(Object::Stream(picture));
|
||
// An ASCII85 layer over LZW with a PNG predictor: the predictor's parameters move to the one
|
||
// Flate filter left, without EarlyChange.
|
||
let rows: Vec<u8> = (0..40 * 30).map(|i| (i % 40 * 3 + i / 40) as u8).collect();
|
||
let png = Params { predictor: 12, columns: 40, early_change: 0, ..Params::default() };
|
||
let mut predicted = filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&lzw(&rows, &png)));
|
||
predicted.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(decode_parms(&png))]));
|
||
let pred = doc.add(Object::Stream(predicted));
|
||
let r = optimize(&mut doc, &clean_up_only()).unwrap();
|
||
assert_eq!(r.lzw_streams, 3, "{r:?}");
|
||
let after = stream(&doc, pred);
|
||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")));
|
||
let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap();
|
||
assert_eq!((parms.int(b"Predictor"), parms.int(b"Columns"), parms.contains(b"EarlyChange")), (Some(12), Some(40), false));
|
||
assert_eq!(after.decoded_strict().unwrap(), rows);
|
||
let after = stream(&doc, c);
|
||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")));
|
||
assert_eq!(after.decoded_strict().unwrap(), ops);
|
||
let after = stream(&doc, pic);
|
||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode"), Object::name("DCTDecode")])));
|
||
assert!(!after.dict.contains(b"DecodeParms"), "all null: dropped");
|
||
let inside = pdfcraft_filters::decode(&[(Filter::Flate, Params::default())], &after.raw, 1 << 20).unwrap();
|
||
assert_eq!(inside, jpeg, "the JPEG bytes are kept exactly");
|
||
}
|
||
|
||
#[test]
|
||
fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() {
|
||
let ops = lzw_ops();
|
||
let good = lzw(&ops, &Params::default());
|
||
// 9-bit codes 511 after a literal: past the table, corrupt.
|
||
let corrupt = vec![0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||
assert!(filtered(&["LZWDecode"], corrupt.clone()).decoded_strict().is_err());
|
||
let mut bad_predictor = filtered(&["LZWDecode"], good.clone());
|
||
let mut parms = Dict::new();
|
||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||
parms.set(b"Columns".to_vec(), Object::Int(7));
|
||
bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
let mut indirect_parms = filtered(&["LZWDecode"], good.clone());
|
||
indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 }));
|
||
// Columns the reader can't take as an integer: read as the default 1 here, so the data is
|
||
// predicted with one column and decodes, but a viewer resolving the value would use another.
|
||
let one_column = Params { predictor: 12, ..Params::default() };
|
||
let odd_columns = |columns: Object| {
|
||
let mut s = filtered(&["LZWDecode"], lzw(&ops, &one_column));
|
||
let mut parms = Dict::new();
|
||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||
parms.set(b"Columns".to_vec(), columns);
|
||
s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||
s
|
||
};
|
||
assert_eq!(odd_columns(Object::Real(12.5)).decoded_strict().unwrap(), ops, "read with one column");
|
||
let mut objstm = filtered(&["LZWDecode"], good.clone());
|
||
objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm"));
|
||
let mut external = filtered(&["LZWDecode"], good.clone());
|
||
external.dict.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"data.bin".to_vec())));
|
||
let cases = [
|
||
("corrupt LZW", filtered(&["LZWDecode"], corrupt), Settings::default()),
|
||
("corrupt under a PNG predictor", bad_predictor, Settings::default()),
|
||
("indirect DecodeParms", indirect_parms, Settings::default()),
|
||
("external file", external, Settings::default()),
|
||
("object stream, rewritten on save", objstm, Settings::default()),
|
||
("Columns by reference", odd_columns(Object::Ref(ObjRef { num: 9, generation: 0 })), Settings::default()),
|
||
("Columns not an integer", odd_columns(Object::Real(12.5)), Settings::default()),
|
||
("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()),
|
||
("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }),
|
||
];
|
||
for (label, s, settings) in cases {
|
||
let (mut doc, c) = with_content(s.clone());
|
||
let r = optimize(&mut doc, &settings).unwrap();
|
||
assert_eq!(r.lzw_streams, 0, "{label}");
|
||
assert_eq!(stream(&doc, c), s, "{label}");
|
||
}
|
||
// The same data, readable and asked for, is converted: the cases above fail for their reason.
|
||
let (mut doc, c) = with_content(filtered(&["LZWDecode"], good));
|
||
assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1);
|
||
assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops);
|
||
}
|