vectorcraft/crates/pdf/src/tests_webview.rs

396 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! General › Embed page thumbnails and Optimize for fast web view: a `/Thumb` image on every page,
//! and a linearised file whose hint tables say where each page's objects are (checked here as a
//! linearisation checker does), also when encrypted; with both off the export is unchanged.
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read};
use image::{DynamicImage, ImageFormat};
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Artboard, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::encrypt::{Lexer, Obj};
use crate::syntax::{Indirect, indirect};
use crate::*;
/// `n` artboards side by side, each with a rectangle; `image` places the same image on every page
/// but the first (shared objects), and on the first too when `on_first`.
fn doc(n: usize, image: bool, on_first: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
for i in 1..n {
let x = 200.0 * i as f64;
d.artboards.push(Artboard {
id: i as u32 + 1,
name: format!("A{i}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
show_center_mark: false,
show_cross_hairs: false,
});
}
let mut png = vec![];
DynamicImage::from(image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([(x * 30) as u8, (y * 30) as u8, 90])))
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.unwrap();
d.images.insert("img".into(), ImageBlob::new("image/png", png));
let layer = d.default_layer().unwrap();
for i in 0..n {
let x = 200.0 * i as f64;
let id = d.alloc_id();
let fill = Paint::solid(Color::rgb(0.2, 0.1 * i as f32, 0.8));
d.insert(
Some(layer),
0,
Node::path(id, shapes::rectangle(Rect::new(x + 10.0, 10.0, x + 50.0, 50.0)), Appearance::basic(fill, Paint::None, 0.0)),
)
.unwrap();
if image && (i > 0 || on_first) {
let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0);
let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() };
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(img))).unwrap();
}
}
d
}
fn options(settings: serde_json::Value) -> PdfOptions {
PdfOptions { settings: serde_json::from_value(settings).unwrap(), created: Some(0), ..Default::default() }
}
/// A thumbnail of `w` × `h` pixels of one grey.
fn thumb(w: u32, h: u32, grey: u8) -> Thumbnail {
Thumbnail { width: w, height: h, rgb: vec![grey; (w * h * 3) as usize] }
}
/// The objects of a (possibly linearised) file, from its cross-reference tables: number → offset.
fn offsets(pdf: &[u8], tables: &[usize]) -> HashMap<u32, usize> {
let mut out = HashMap::new();
for &at in tables {
let mut lx = Lexer::at(pdf, at);
assert!(matches!(lx.next(), Some((crate::encrypt::Tok::Word(b"xref"), ..))), "a table at {at}");
let word = |lx: &mut Lexer| match lx.next() {
Some((crate::encrypt::Tok::Word(w), ..)) => std::str::from_utf8(w).unwrap().to_string(),
t => panic!("{t:?}"),
};
let (first, count): (u32, u32) = (word(&mut lx).parse().unwrap(), word(&mut lx).parse().unwrap());
for n in first..first + count {
let (off, _, kind) = (word(&mut lx), word(&mut lx), word(&mut lx));
if kind == "n" {
out.insert(n, off.parse().unwrap());
}
}
}
out
}
fn int(o: &Obj, key: &[u8]) -> usize {
usize::try_from(o.int(key).unwrap_or_else(|| panic!("{}", String::from_utf8_lossy(key)))).unwrap()
}
fn reference(o: &Obj, key: &[u8]) -> u32 {
match o.get(key) {
Some(Obj::Ref(n, _)) => *n,
_ => panic!("{} is no reference", String::from_utf8_lossy(key)),
}
}
/// Bits read most significant first.
struct BitReader<'a> {
data: &'a [u8],
at: usize,
}
impl BitReader<'_> {
fn get(&mut self, bits: u64) -> u64 {
(0..bits).fold(0, |v, _| {
let bit = self.data[self.at / 8] >> (7 - self.at % 8) & 1;
self.at += 1;
v << 1 | u64::from(bit)
})
}
fn align(&mut self) {
self.at = self.at.div_ceil(8) * 8;
}
/// One item for each of `n` entries, then the padding.
fn column(&mut self, n: usize, bits: u64) -> Vec<u64> {
let v = (0..n).map(|_| self.get(bits)).collect();
self.align();
v
}
}
/// What a linearisation check finds in `pdf`.
struct Linearised {
pages: Vec<u32>,
/// The objects of the first page's cross-reference table.
first_part: HashSet<u32>,
encrypted: bool,
}
/// Check `pdf` is linearised as ISO 32000-1 Annex F says, as a linearisation checker does: the
/// linearization dictionary, both cross-reference tables and their trailers, and (unless the file
/// is encrypted) the hint tables against where every page's objects are and what each page uses.
fn check_linearised(pdf: &[u8]) -> Linearised {
// The linearization dictionary is the first object.
let first_obj = crate::lab_spot::find(pdf, b" 0 obj", 0).unwrap();
let lin_at = pdf[..first_obj].iter().rposition(|b| *b == b'\n').unwrap() + 1;
let lin = indirect(pdf, lin_at).unwrap();
assert_eq!(lin.value.int(b"Linearized"), Some(1));
assert_eq!(int(&lin.value, b"L"), pdf.len(), "/L is the file's length");
// The first page's table follows it, its trailer points at the main table (/Prev), and the
// file's last startxref at it.
let first_table = lin.endobj + 7;
assert!(pdf[first_table..].starts_with(b"xref"));
let last = crate::lab_spot::rfind(pdf, b"startxref").unwrap();
assert_eq!(std::str::from_utf8(&pdf[last + 9..]).unwrap().split_whitespace().next().unwrap().parse::<usize>().unwrap(), first_table);
let trailer_at = crate::lab_spot::find(pdf, b"trailer", first_table).unwrap() + 7;
let trailer = Lexer::at(pdf, trailer_at).object(0).unwrap();
let main = int(&trailer, b"Prev");
let size = int(&trailer, b"Size") as u32;
let first_part: HashSet<u32> = offsets(pdf, &[first_table]).into_keys().collect();
let all = offsets(pdf, &[first_table, main]);
assert_eq!(all.len() as u32, size - 1, "the tables cover every object");
let first = lin.num;
assert!(first_part.iter().all(|n| (first..size).contains(n)) && first_part.len() as u32 == size - first);
// /T: the white space before the main table's first entry.
let entries = main + format!("xref\n0 {first}\n").len();
assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}");
assert_eq!(int(&lin.value, b"T"), entries - 1);
let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap();
assert_eq!(int(&main_trailer, b"Size") as u32, first);
// Every object where its table says, read as the file's objects.
let objects: HashMap<u32, Indirect> = all.iter().map(|(n, off)| (*n, indirect(pdf, *off).unwrap())).collect();
assert!(objects.iter().all(|(n, o)| o.num == *n));
// Each object's span: to the next object, or to the table after it.
let mut starts: Vec<usize> = all.values().copied().chain([first_table, main]).collect();
starts.sort_unstable();
let span = |n: u32| {
let at = all[&n];
starts[starts.partition_point(|s| *s <= at)] - at
};
let root = &objects[&reference(&trailer, b"Root")];
let mut pages = vec![];
let mut nodes = HashSet::new();
crate::linearize::page_tree(&objects, reference(&root.value, b"Pages"), 0, &mut pages, &mut nodes).unwrap();
assert_eq!(int(&lin.value, b"N"), pages.len());
assert_eq!(int(&lin.value, b"O") as u32, pages[0], "/O is the first page");
// The hint stream: /H gives where it is and how long.
let Some(Obj::Array(h)) = lin.value.get(b"H") else { panic!("no /H") };
let [Obj::Int { value: h_at, .. }, Obj::Int { value: h_len, .. }] = &h[..] else { panic!("bad /H") };
let (h_at, h_len) = (*h_at as usize, *h_len as usize);
let hint = indirect(pdf, h_at).unwrap();
assert_eq!(h_len, span(hint.num));
// Offsets in the hint tables leave the hint stream out.
let adjusted = |n: u32| if all[&n] > h_at { all[&n] - h_len } else { all[&n] };
// /E: the end of the first page (the encryption dictionary is numbered after it).
let encrypt = trailer.get(b"Encrypt").map(|_| reference(&trailer, b"Encrypt"));
let encrypted = encrypt.is_some();
let first_page_objects = (pages[0]..).take_while(|n| first_part.contains(n) && Some(*n) != encrypt).count() as u32;
let first_end = all[&(pages[0] + first_page_objects - 1)] + span(pages[0] + first_page_objects - 1);
assert_eq!(int(&lin.value, b"E"), first_end, "/E ends the first page");
if encrypted {
return Linearised { pages, first_part, encrypted };
}
let data_at = crate::lab_spot::find(pdf, b"stream", hint.value_span.1).unwrap() + 7;
let data = &pdf[data_at..data_at + int(&hint.value, b"Length")];
let mut r = BitReader { data, at: 0 };
// The page offset hint table.
let (min_objects, first_page_at, objects_bits) = (r.get(32), r.get(32) as usize, r.get(16));
let (min_len, len_bits) = (r.get(32), r.get(16));
let (_content_at, content_at_bits, _content_len, content_len_bits) = (r.get(32), r.get(16), r.get(32), r.get(16));
let (nshared_bits, id_bits, numerator_bits, _denominator) = (r.get(16), r.get(16), r.get(16), r.get(16));
assert_eq!(first_page_at, adjusted(pages[0]), "the first page's location");
let n = pages.len();
let counts: Vec<u64> = r.column(n, objects_bits).iter().map(|c| c + min_objects).collect();
let lengths: Vec<u64> = r.column(n, len_bits).iter().map(|l| l + min_len).collect();
let nshared = r.column(n, nshared_bits);
let ids: Vec<Vec<u64>> = nshared.iter().map(|k| (0..*k).map(|_| r.get(id_bits)).collect()).collect();
r.align();
r.column(nshared.iter().sum::<u64>() as usize, numerator_bits);
r.column(n, content_at_bits);
r.column(n, content_len_bits);
assert_eq!(counts[0] as u32, first_page_objects, "the first page's object count");
assert_eq!(nshared[0], 0, "the first page lists no shared objects");
let mut at = first_page_at;
for (i, &page) in pages.iter().enumerate() {
// A page's objects are numbered on from its page object, and lie together from it.
assert_eq!(adjusted(page), at, "page {i} starts where the pages before it end");
let len: usize = (page..page + counts[i] as u32).map(span).sum();
assert_eq!(len as u64, lengths[i], "page {i}'s length");
at += len;
}
// The shared object hint table.
let s = int(&hint.value, b"S");
let mut r = BitReader { data, at: 8 * s };
let (first_shared, first_shared_at, nfirst, ntotal) = (r.get(32) as u32, r.get(32) as usize, r.get(32), r.get(32));
let (group_bits, min_group, group_len_bits) = (r.get(16), r.get(32), r.get(16));
assert_eq!(group_bits, 0, "one object a group");
assert_eq!(nfirst as u32, first_page_objects);
let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 };
if ntotal > nfirst {
assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location");
}
let groups = r.column(ntotal as usize, group_len_bits);
for (id, g) in groups.iter().enumerate() {
assert_eq!((g + min_group) as usize, span(shared_object(id as u64)), "shared object {id}'s length");
}
assert!(r.column(ntotal as usize, 1).iter().all(|s| *s == 0), "no signatures");
// What each page uses lies in its own objects, the first part or its shared objects.
let page_set: HashSet<u32> = pages.iter().copied().collect();
for (i, &page) in pages.iter().enumerate() {
let own: HashSet<u32> = (page..page + counts[i] as u32).collect();
let listed: HashSet<u32> = ids[i].iter().map(|id| shared_object(*id)).collect();
let (mut seen, mut queue) = (HashSet::from([page]), vec![page]);
while let Some(o) = queue.pop() {
assert!(
own.contains(&o) || first_part.contains(&o) && (i == 0 || o < pages[0] || listed.contains(&o)) || listed.contains(&o),
"page {i} uses object {o}, which isn't among its objects or listed"
);
let mut links = vec![];
objects[&o].value.refs(&[b"Parent", b"Thumb"], &mut links);
queue.extend(links.into_iter().filter(|m| !page_set.contains(m) && !nodes.contains(m) && seen.insert(*m)));
}
assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses");
}
Linearised { pages, first_part, encrypted }
}
#[test]
fn thumbnails_are_embedded_on_every_page() {
let d = doc(3, false, false);
let mut o = options(json!({"thumbnails": true}));
o.thumbnails = vec![thumb(106, 106, 10), thumb(53, 106, 20), thumb(106, 1, 30)];
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let parsed = crate::linearize::parse(&r.bytes).unwrap();
for (page, t) in parsed.pages.iter().zip(&o.thumbnails) {
let thumb = &parsed.objects[&reference(&parsed.objects[page].value, b"Thumb")];
assert_eq!((int(&thumb.value, b"Width"), int(&thumb.value, b"Height")), (t.width as usize, t.height as usize));
assert_eq!(thumb.value.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
let at = crate::lab_spot::find(&r.bytes, b"stream", thumb.value_span.1).unwrap() + 7;
let mut rgb = vec![];
flate2::read::ZlibDecoder::new(&r.bytes[at..at + int(&thumb.value, b"Length")]).read_to_end(&mut rgb).unwrap();
assert_eq!(rgb, t.rgb, "the pixels as given");
}
// The file reads back: three pages, the thumbnails aside.
assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3);
// Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused.
let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none());
o.thumbnails[1] = thumb(107, 20, 0);
assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_))));
}
#[test]
fn fast_web_view_writes_a_linearised_file_whose_hints_check() {
for (pages, image, on_first) in [(1, false, false), (2, false, false), (3, true, false), (4, true, true)] {
let d = doc(pages, image, on_first);
let mut o = options(json!({"fastWebView": true, "compression": {"compressText": pages % 2 == 0}}));
let plain = export(&d, &options(json!({"compression": {"compressText": pages % 2 == 0}}))).unwrap();
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(r.bytes.starts_with(b"%PDF-1.7"));
let l = check_linearised(&r.bytes);
assert_eq!(l.pages.len(), pages);
assert!(l.first_part.contains(&l.pages[0]) && !l.encrypted);
// The same pages: it reads back as the plain file does.
let (a, b) = (import(&r.bytes).unwrap(), import(&plain).unwrap());
assert_eq!(a.artboards.len(), b.artboards.len());
assert_eq!(format!("{:?}", a.layers), format!("{:?}", b.layers), "{pages} pages");
// With thumbnails and the editing data too (both after the pages).
o.settings.thumbnails = true;
o.settings.preserve_editing = true;
o.native = Some(br#"{"pages":1}"#.to_vec());
o.thumbnails = (0..pages).map(|i| thumb(20, 20, i as u8)).collect();
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
check_linearised(&r.bytes);
let editing = editing(&r.bytes).unwrap();
assert!(editing.intact, "the editing data's seal holds");
assert_eq!(editing.data, br#"{"pages":1}"#);
}
}
#[test]
fn encrypted_files_stay_linearised() {
let d = doc(3, true, true);
for c in ["1.4", "1.5", "1.6", "1.7", "2.0"] {
let o = options(json!({"fastWebView": true, "compatibility": c, "security": {"openPassword": "open", "plaintextMetadata": c != "1.4"}}));
let bytes = export(&d, &o).unwrap();
let l = check_linearised(&bytes);
assert!(l.encrypted && l.pages.len() == 3, "{c}");
// The encryption dictionary is in the first part (opening needs it).
let trailer_at = crate::lab_spot::find(&bytes, b"trailer", 0).unwrap() + 7;
let trailer = Lexer::at(&bytes, trailer_at).object(0).unwrap();
assert!(l.first_part.contains(&reference(&trailer, b"Encrypt")), "{c}");
assert!(matches!(import(&bytes), Err(PdfError::NeedsPassword)), "{c}");
let opened = import_with_report(&bytes, &ImportOptions { password: Some("open".into()), ..Default::default() }).unwrap();
assert_eq!(opened.document.artboards.len(), 3, "{c}");
}
}
#[test]
fn with_both_off_the_file_is_unchanged() {
let d = doc(2, true, false);
let off = options(json!({"thumbnails": false, "fastWebView": false}));
let plain = export(&d, &options(json!({}))).unwrap();
assert_eq!(export(&d, &off).unwrap(), plain, "the default export");
// Finishing leaves the written file as it is.
let mut warnings = vec![];
assert_eq!(crate::post::finish(plain.clone(), &off, false, &mut warnings).unwrap(), plain);
assert!(warnings.is_empty());
// Thumbnails given but not asked for are left out.
let given = PdfOptions { thumbnails: vec![thumb(10, 10, 0); 2], ..off };
assert_eq!(export(&d, &given).unwrap(), plain);
}
#[test]
fn pdf_x_files_take_thumbnails_and_stay_linearised() {
let d = doc(3, true, false);
for standard in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
let mut o = options(json!({"thumbnails": true, "fastWebView": true}));
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
o.thumbnails = vec![thumb(30, 30, 200); 3];
let r = export_with_report(&d, &o).unwrap();
let header = if standard == Standard::PdfX4 { "%PDF-1.6" } else { "%PDF-1.3" };
assert!(r.bytes.starts_with(header.as_bytes()), "{standard:?}");
assert_eq!(check_linearised(&r.bytes).pages.len(), 3, "{standard:?}");
// Still the standard: grey thumbnails (no RGB in PDF/X-1a), nothing it forbids.
crate::pdfx::verify(&r.bytes, standard).unwrap();
assert_eq!(
String::from_utf8_lossy(&r.bytes).matches("/ColorSpace/DeviceGray/BitsPerComponent 8/Filter/FlateDecode").count(),
3,
"{standard:?}"
);
}
}
#[test]
fn pdf_layers_stay_with_a_linearised_file() {
let mut d = doc(3, true, true);
let l = d.add_layer(Some("Notes"));
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(220.0, 20.0, 260.0, 60.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
let r = export_with_report(&d, &options(json!({"fastWebView": true, "createLayers": true}))).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let l = check_linearised(&r.bytes);
// The optional content groups open the document: they come first, with the catalog.
let text = String::from_utf8_lossy(&r.bytes);
let at = text.find("/OCGs[").unwrap() + 6;
let ocgs: Vec<u32> = text[at..at + text[at..].find(']').unwrap()].split(" 0 R").filter_map(|n| n.trim().parse().ok()).collect();
assert!(ocgs.len() == 2 && ocgs.iter().all(|n| l.first_part.contains(n)), "{ocgs:?}");
assert_eq!(l.pages.len(), 3);
let back = import(&r.bytes).unwrap();
assert!(back.layers.len() >= 2, "the layers come back");
}