mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
166 lines
7.7 KiB
Rust
166 lines
7.7 KiB
Rust
//! Contributor-original PDFs exercise optimization across content stream boundaries.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use pdfcraft_automation::{Automation, Content};
|
|
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
|
use serde_json::{Value, json};
|
|
|
|
const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]];
|
|
|
|
fn fixture(ending: &str, split_operands: bool) -> Vec<u8> {
|
|
let mut doc = Document::new_empty();
|
|
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
|
let font = doc.add(Object::Dict(Dict::from_iter([
|
|
(b"Type".to_vec(), Object::name("Font")),
|
|
(b"Subtype".to_vec(), Object::name("Type1")),
|
|
(b"BaseFont".to_vec(), Object::name("Helvetica")),
|
|
])));
|
|
let mut xobjects = Dict::new();
|
|
for (i, color) in COLORS.iter().enumerate() {
|
|
let dict = Dict::from_iter([
|
|
(b"Type".to_vec(), Object::name("XObject")),
|
|
(b"Subtype".to_vec(), Object::name("Image")),
|
|
(b"Width".to_vec(), 20.into()),
|
|
(b"Height".to_vec(), 5.into()),
|
|
(b"BitsPerComponent".to_vec(), 8.into()),
|
|
(b"ColorSpace".to_vec(), Object::name("DeviceRGB")),
|
|
]);
|
|
let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100))));
|
|
xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image));
|
|
}
|
|
// q and the translation stay active across both pieces. The optional split places
|
|
// the final two cm operands and its operator in the next stream.
|
|
let first = if split_operands {
|
|
"q 1 0 0 1".to_string()
|
|
} else {
|
|
format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}")
|
|
};
|
|
let mut second = if split_operands {
|
|
"10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string()
|
|
} else {
|
|
"BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string()
|
|
};
|
|
for i in 0..4 {
|
|
second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5));
|
|
}
|
|
second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n");
|
|
let contents: Vec<Object> =
|
|
[first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect();
|
|
let resources = Dict::from_iter([
|
|
(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))),
|
|
(b"XObject".to_vec(), Object::Dict(xobjects)),
|
|
]);
|
|
let page = doc.add(Object::Dict(Dict::from_iter([
|
|
(b"Type".to_vec(), Object::name("Page")),
|
|
(b"Parent".to_vec(), Object::Ref(pages)),
|
|
(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])),
|
|
(b"Resources".to_vec(), Object::Dict(resources)),
|
|
(b"Contents".to_vec(), Object::Array(contents)),
|
|
])));
|
|
doc.update_dict(pages, |dict| {
|
|
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
|
dict.set(b"Count".to_vec(), Object::Int(1));
|
|
})
|
|
.unwrap();
|
|
write_full(&doc, &SaveOptions::default()).unwrap()
|
|
}
|
|
|
|
fn call(a: &mut Automation, tool: &str, args: Value) -> Value {
|
|
let result = a.call(tool, &args).unwrap();
|
|
let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") };
|
|
value.clone()
|
|
}
|
|
|
|
fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) {
|
|
let text = call(a, "text_extract", json!({"doc": doc}));
|
|
let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect();
|
|
assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text");
|
|
let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
|
|
let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") };
|
|
assert_eq!((*width, *height), (600, 800));
|
|
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
|
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
|
let info = reader.next_frame(&mut pixels).unwrap();
|
|
let channels = info.color_type.samples();
|
|
assert!(channels >= 3);
|
|
let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3];
|
|
let dark = |left, top, right, bottom| {
|
|
(top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count()
|
|
};
|
|
assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position");
|
|
assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position");
|
|
assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending");
|
|
for (row, color) in COLORS.iter().enumerate() {
|
|
for y in 581 + row * 5..584 + row * 5 {
|
|
for x in 112..128 {
|
|
assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}");
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active");
|
|
}
|
|
|
|
fn check_saved_images(bytes: Vec<u8>, merged: bool) {
|
|
let doc = Document::open(Arc::new(bytes)).unwrap();
|
|
let root = doc.get(doc.root().unwrap());
|
|
let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap());
|
|
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
|
|
let page = doc.resolve(&kids[0]);
|
|
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
|
|
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
|
|
let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect();
|
|
assert_eq!(images.len(), if merged { 1 } else { 4 });
|
|
if merged {
|
|
let Object::Stream(image) = &*images[0] else { panic!("expected saved image") };
|
|
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20)));
|
|
let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect();
|
|
assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order");
|
|
}
|
|
}
|
|
|
|
fn roundtrip(ending: &str, split_operands: bool, merge: bool) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap();
|
|
let mut a = Automation::new().with_root(dir.path()).unwrap();
|
|
let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap();
|
|
let hidden = !ending.is_empty() && !split_operands;
|
|
check_page(&mut a, doc, hidden, "original");
|
|
let report = call(
|
|
&mut a,
|
|
"doc_optimize",
|
|
json!({
|
|
"doc": doc, "path": "output.pdf", "merge_fragments": merge,
|
|
"color": {"downsample": false, "compression": "retain"},
|
|
"gray": {"downsample": false, "compression": "retain"}
|
|
}),
|
|
);
|
|
assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 });
|
|
assert_eq!(report["merged_images"], if merge { 1 } else { 0 });
|
|
let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap();
|
|
check_page(&mut a, reopened, hidden, "reopened");
|
|
check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge);
|
|
}
|
|
|
|
#[test]
|
|
fn fragment_merge_preserves_a_comment_across_content_pieces() {
|
|
roundtrip("", false, true);
|
|
}
|
|
|
|
#[test]
|
|
fn fragment_merge_preserves_explicit_comment_line_endings() {
|
|
for ending in ["\n", "\r", "\r\n"] {
|
|
roundtrip(ending, false, true);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fragment_merge_preserves_split_operands_and_graphics_state() {
|
|
roundtrip("", true, true);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_fragment_merge_preserves_content_pieces() {
|
|
roundtrip("", false, false);
|
|
}
|