Merge pull request #5410 from storytold/fix/sec-pdf-import-bounds

PDF: bound marked-content lookup, ASCII85, editing-data hashing, image decodes and ink warnings
This commit is contained in:
BFlat
2026-11-03 23:53:24 +07:00
committed by GitHub
11 changed files with 205 additions and 44 deletions
+33 -14
View File
@@ -6,7 +6,8 @@ use std::collections::HashSet;
use std::sync::Arc;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Object, ObjectIdentifier, Stream, String as PdfString};
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Object, ObjectIdentifier, Stream, String as PdfString};
use craft_pdf::syntax::xref::XRef;
use craft_pdf::write::backend as krilla;
use krilla::embed::{AssociationKind, EmbeddedFile, MimeType};
use krilla::metadata::DateTime;
@@ -81,7 +82,7 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
let xref = pdf.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id())?;
let tree = catalog.get::<Dict<'_>>(b"Names")?.get::<Dict<'_>>(b"EmbeddedFiles")?;
let spec = editing_spec(&tree, 0, &mut HashSet::new())?;
let spec = editing_spec(&tree, 0, &mut HashSet::new(), xref)?;
let files = spec.get::<Dict<'_>>(b"EF")?;
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
@@ -97,7 +98,7 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
/// The file specification of the editing file in the name tree node `node`. `seen` holds the indirect
/// nodes already searched, so a node shared by many `Kids` arrays is searched once, not once per path.
fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIdentifier>) -> Option<Dict<'a>> {
fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIdentifier>, xref: &'a XRef) -> Option<Dict<'a>> {
if let Some(names) = node.get::<Array<'a>>(b"Names") {
let mut items = names.iter::<Object<'a>>();
while let (Some(_), Some(spec)) = (items.next(), items.next()) {
@@ -112,12 +113,15 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
return None;
}
let kids = node.get::<Array<'a>>(b"Kids")?;
// Only a referenced kid can be shared (a direct kid's `obj_id` is the object holding it).
items(&kids).zip(kids.iter::<Dict<'a>>()).find_map(|(raw, kid)| {
if raw.as_obj_ref().is_some_and(|r| !seen.insert(r.into())) {
return None;
}
editing_spec(&kid, depth + 1, seen)
// Only a referenced kid can be shared (a direct kid's `obj_id` is the object holding it). The
// reference is checked before the kid is read, so a shared kid is read once.
items(&kids).find_map(|raw| {
let kid = match raw {
MaybeRef::Ref(r) if seen.insert(r.into()) => xref.get::<Dict<'a>>(r.into())?,
MaybeRef::NotRef(Object::Dict(kid)) => kid,
_ => return None,
};
editing_spec(&kid, depth + 1, seen, xref)
})
}
@@ -130,9 +134,26 @@ fn is_editing_file(spec: &Dict<'_>) -> bool {
})
}
/// FNV-1a of the pages' media boxes and decoded content, as 16 hex digits.
/// The most decoded page content [`page_hash`] reads in all (bytes).
const MAX_HASHED: usize = 256 << 20;
/// FNV-1a of the pages' media boxes and decoded content, as 16 hex digits. Past [`MAX_HASHED`]
/// bytes of content in all (pages sharing a stream count it each time) a marker stands for the
/// rest, so writing and reading a file hash it the same way within bounded work.
fn page_hash(pdf: &Pdf) -> String {
let fnv = |h: u64, bytes: &[u8]| bytes.iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));
let mut left = MAX_HASHED;
let mut content = |h: u64, stream: &Stream<'_>| {
if left > 0
&& let Ok(data) = stream.decoded_within(left)
&& data.len() < left
{
left -= data.len();
return fnv(h, &data);
}
left = 0;
fnv(h, b"\0past the hashed content")
};
let mut h = 0xcbf29ce484222325;
for page in pdf.pages().iter() {
let b = page.media_box();
@@ -141,12 +162,10 @@ fn page_hash(pdf: &Pdf) -> String {
// page's copy for as long as the PDF lives, even when pages share one stream.
let contents = page.raw();
if let Some(stream) = contents.get::<Stream<'_>>(b"Contents") {
h = fnv(h, stream.decoded().as_deref().unwrap_or_default());
h = content(h, &stream);
} else if let Some(array) = contents.get::<Array<'_>>(b"Contents") {
for stream in array.iter::<Stream<'_>>() {
if let Ok(data) = stream.decoded() {
h = fnv(fnv(h, &data), b" ");
}
h = fnv(content(h, &stream), b" ");
}
}
}
+30 -3
View File
@@ -229,7 +229,11 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
let keep = Recoded { image: None, warning: None };
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
let Ok(decoder) = reader.into_decoder() else { return keep };
let Ok(mut decoder) = reader.into_decoder() else { return keep };
// What the header declares is decoded within the image allowance, not whatever it asks for.
if decoder.set_limits(limits()).is_err() {
return keep;
}
let dims = decoder.dimensions();
use ExtendedColorType as T;
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
@@ -249,7 +253,7 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
if kept(&rule, dims, size, jpeg) && convert.is_none() {
return Recoded { image: None, warning: rule.instead };
}
let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep };
let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep };
let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8();
if kind == Kind::Mono {
// Resampling greys the edges: back to black and white.
@@ -345,6 +349,23 @@ pub(crate) fn recode_cmyk(
Recoded { image, warning: rule.instead }
}
/// The most one image read here may allocate (bytes), as the image reader allows by default.
const MAX_DECODE: u64 = 512 << 20;
/// The decoder limits of an image read here: [`MAX_DECODE`] bytes.
fn limits() -> image::Limits {
let mut l = image::Limits::default();
l.max_alloc = Some(MAX_DECODE);
l
}
/// `bytes` decoded within [`limits`]: an image whose header asks for more is not decoded.
fn decoded(bytes: &[u8]) -> Option<DynamicImage> {
let mut r = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
r.limits(limits());
r.decode().ok()
}
/// The alpha an opacity mask whose art is greyscale image `blob` alone gives the image of
/// `width` × `height` pixels it covers pixel for pixel: the mask's luminance, over black where the
/// mask image is transparent when it clips (`clip`), else over white. `None` when `blob` isn't
@@ -353,7 +374,13 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool)
if blob.is_cmyk() {
return None;
}
let img = image::load_from_memory(&blob.bytes).ok()?.into_rgba8();
// The size from the header first: a mask of another size, or too large to hold as RGBA, is
// not decoded at all.
let reader = ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?;
if reader.into_dimensions().ok()? != (width, height) || u64::from(width) * u64::from(height) * 4 > MAX_DECODE {
return None;
}
let img = decoded(&blob.bytes)?.into_rgba8();
if img.dimensions() != (width, height) || !neutral(img.as_raw()) {
return None;
}
+2 -2
View File
@@ -173,8 +173,8 @@ impl<'p> Builder<'p> {
}
/// The group art drawn now belongs to (the innermost marked as one).
fn current_group(&self) -> Option<usize> {
self.marked.iter().rev().find_map(|g| *g)
pub(super) fn current_group(&self) -> Option<usize> {
self.marked.last().and_then(|(_, current)| *current)
}
/// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it
+3 -2
View File
@@ -363,7 +363,8 @@ impl<'a> Device<'a> for Builder<'_> {
{
f.group.get_or_insert(g);
}
self.marked.push(group);
let current = group.or_else(|| self.current_group());
self.marked.push((group, current));
}
fn end_marked_content(&mut self) {
@@ -371,7 +372,7 @@ impl<'a> Device<'a> for Builder<'_> {
return;
}
// The runs gathered within a layer's sequence are its art: finish them before it ends.
if self.marked.last().copied().flatten().is_some() {
if self.marked.last().is_some_and(|(group, _)| group.is_some()) {
self.flush();
}
self.marked.pop();
+3 -2
View File
@@ -136,8 +136,9 @@ pub(super) struct Builder<'p> {
/// Some groups are hidden or don't print: art in marked content whose group can't be told
/// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's.
pub(super) unsure: bool,
/// The marked-content sequences open: the group each marks.
pub(super) marked: Vec<Option<usize>>,
/// The marked-content sequences open: the group each marks, and the innermost group marked
/// at or around it (kept per level, so art drawn deep in sequences finds its group at once).
pub(super) marked: Vec<(Option<usize>, Option<usize>)>,
pub(super) nested: u32,
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
+26 -11
View File
@@ -14,15 +14,20 @@ use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings};
/// The inks of a separation of `doc`: the process inks, then one per spot swatch (none when
/// spots print as process), with the options of [`super::PrintOutput::inks`]; and a warning for
/// each option naming no ink.
/// spots print as process), with the options of [`super::PrintOutput::inks`]; and a warning
/// naming the options that name no ink.
pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<String>) {
let out = &set.output;
// Options by ink name, the first for a name winning: looked up, not searched for each ink.
let mut options = std::collections::HashMap::new();
for o in &out.inks {
options.entry(o.name.as_str()).or_insert(o);
}
let list: Vec<PrintInk> = plates(doc)
.into_iter()
.filter(|p| !(p.spot && out.spots_to_process))
.map(|p| {
let o = out.inks.iter().find(|i| i.name == p.name);
let o = options.get(p.name.as_str()).copied();
PrintInk {
print: o.is_none_or(|o| o.print),
frequency: o.and_then(|o| o.frequency).unwrap_or(DEFAULT_FREQUENCY),
@@ -32,17 +37,27 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
}
})
.collect();
let warnings = out
.inks
.iter()
.filter(|i| !list.iter().any(|p| p.name == i.name))
.map(|i| {
format!("there is no ink named `{}` to print (inks: {})", i.name, list.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", "))
})
.collect();
// One warning for all the options naming no ink, each list cut short: the options and the
// inks both come from the file.
let names: BTreeSet<&str> = list.iter().map(|p| p.name.as_str()).collect();
let unknown: Vec<&str> = out.inks.iter().map(|i| i.name.as_str()).filter(|n| !names.contains(n)).collect();
let warnings = if unknown.is_empty() {
vec![]
} else {
let few = |all: Vec<&str>| {
let more = all.len().saturating_sub(MAX_NAMED);
let shown = all.into_iter().take(MAX_NAMED).map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ");
if more > 0 { format!("{shown} and {more} more") } else { shown }
};
let inks = few(list.iter().map(|p| p.name.as_str()).collect());
vec![format!("there is no ink named {} to print (inks: {inks})", few(unknown))]
};
(list, warnings)
}
/// The most names a warning of [`print_inks`] lists.
const MAX_NAMED: usize = 10;
/// The colour settings separations of `doc` convert with: the document's colour spaces
/// ([`crate::output::document_cms`]), with the printer profile as their CMYK profile when it is a
/// CMYK one.
+13 -5
View File
@@ -116,7 +116,7 @@ fn hex(data: &[u8]) -> Vec<u8> {
}
/// `data` decoded from base-85 (white space skipped, `z` for four zeros, up to `~>`).
fn base85(data: &[u8]) -> Vec<u8> {
fn base85(data: &[u8], max: usize) -> Vec<u8> {
let (mut out, mut group) = (vec![], vec![]);
let flush = |group: &mut Vec<u32>, out: &mut Vec<u8>| {
let n = group.len();
@@ -128,6 +128,10 @@ fn base85(data: &[u8]) -> Vec<u8> {
group.clear();
};
for &c in data.iter().take_while(|c| **c != b'~') {
// Past `max` the caller refuses it: stop growing (a `z` is four bytes from one).
if out.len() > max {
break;
}
match c {
b'z' if group.is_empty() => out.extend([0; 4]),
b'!'..=b'u' => {
@@ -144,13 +148,15 @@ fn base85(data: &[u8]) -> Vec<u8> {
}
/// The most data of `len` bytes can grow to through `filters`: 1,032 times through Flate (its
/// largest ratio), 3,641 through LZW (4,096 bytes from a 9-bit code), 64 through run-length.
/// largest ratio), 3,641 through LZW (4,096 bytes from a 9-bit code), 64 through run-length, 4
/// through ASCII85 (`z`).
fn most_after(len: usize, filters: &[Name<'_>]) -> u128 {
filters.iter().fold(len as u128, |n, f| {
n.saturating_mul(match f.as_ref() {
b"FlateDecode" | b"Fl" => 1032,
b"LZWDecode" | b"LZW" => 3641,
b"RunLengthDecode" | b"RL" => 64,
b"ASCII85Decode" | b"A85" => 4,
_ => 1,
})
})
@@ -213,7 +219,7 @@ fn decode(data: &[u8], dict: &Dict<'_>, keep: bool) -> Result<Option<Vec<u8>>, T
b"LZWDecode" | b"LZW" => lzw(&data, early, max),
b"RunLengthDecode" | b"RL" => Some(run_length(&data, max)),
b"ASCIIHexDecode" | b"AHx" => Some(hex(&data)),
b"ASCII85Decode" | b"A85" => Some(base85(&data)),
b"ASCII85Decode" | b"A85" => Some(base85(&data, max)),
_ => None,
};
match out {
@@ -740,8 +746,10 @@ mod tests {
#[test]
fn the_decoders_read_what_the_specification_writes() {
assert_eq!(hex(b"48 65 6c6C6\n>junk"), b"Hell`");
assert_eq!(base85(b"87cURD]i,\"Ebo80~>"), b"Hello World!");
assert_eq!(base85(b"z!!~>"), [0, 0, 0, 0, 0]);
assert_eq!(base85(b"87cURD]i,\"Ebo80~>", usize::MAX), b"Hello World!");
assert_eq!(base85(b"z!!~>", usize::MAX), [0, 0, 0, 0, 0]);
// Zeros past the allowance are not written: a little over it, and the caller refuses it.
assert!(base85(&[b'z'; 1 << 20], 1000).len() <= 1004);
assert_eq!(run_length(&[2, b'a', b'b', b'c', 254, b'x', 128, b'q'], MAX_DECODED), b"abcxxx");
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut e, &[7; 1000]).unwrap();
+12
View File
@@ -179,3 +179,15 @@ fn a_pixelated_colorized_image_asks_viewers_not_to_smooth_it() {
assert_eq!(String::from_utf8_lossy(&out).contains("/Interpolate"), !pixelated, "pixelated: {pixelated}");
}
}
/// Ink options naming no ink give one warning, its lists cut short, however many there are.
#[test]
fn ink_options_naming_no_ink_give_one_short_warning() {
let d = doc(ColorMode::Cmyk, None);
let inks: Vec<serde_json::Value> = (0..10_000).map(|i| serde_json::json!({"name": format!("Missing {i}")})).collect();
let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations", "inks": inks}})).unwrap();
let (list, warnings) = print_inks(&d, &settings);
assert!(list.iter().any(|i| i.name == "Cyan"));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("`Missing 0`") && warnings[0].contains("and 9990 more") && warnings[0].len() < 500, "{}", warnings[0]);
}
+29
View File
@@ -158,3 +158,32 @@ fn codecs_the_writer_lacks_are_zip_with_a_warning() {
// The codecs of other kinds of images say nothing.
assert!(pdf(&png, json!({"mono": {"compression": "ccittG4"}})).1.is_empty());
}
/// A PNG whose header declares `w` × `h` grey pixels and whose data holds a few: what a file can
/// declare, not what it holds.
fn declared_png(w: u32, h: u32) -> Vec<u8> {
let chunk = |kind: &[u8], data: &[u8]| {
let mut crc = flate2::Crc::new();
crc.update(kind);
crc.update(data);
[&(data.len() as u32).to_be_bytes()[..], kind, data, &crc.sum().to_be_bytes()].concat()
};
let ihdr = [&w.to_be_bytes()[..], &h.to_be_bytes(), &[8, 0, 0, 0, 0]].concat();
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, &[0; 64]).unwrap();
let idat = z.finish().unwrap();
[&b"\x89PNG\r\n\x1a\n"[..], &chunk(b"IHDR", &ihdr), &chunk(b"IDAT", &idat), &chunk(b"IEND", &[])].concat()
}
/// Images a document holds are decoded within the decode allowance: a header declaring billions of
/// pixels is refused before they are allocated, as an opacity mask and when recoded.
#[test]
fn images_declaring_more_than_the_allowance_are_not_decoded() {
let png = declared_png(60_000, 60_000);
let blob = ImageBlob::new("image/png", png.clone());
assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None);
assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all");
let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap();
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None);
assert!(r.image.is_none(), "kept as it is");
}
+42 -5
View File
@@ -131,18 +131,25 @@ fn pdf_a_carries_no_editing_data() {
/// A tiny PDF made of the given objects (numbered from 1), with the cross-reference table written.
fn raw_pdf(objects: &[String]) -> Vec<u8> {
raw_pdf_bytes(objects.iter().map(|o| o.clone().into_bytes()))
}
/// [`raw_pdf`] of objects that may hold binary data.
fn raw_pdf_bytes(objects: impl IntoIterator<Item = Vec<u8>>) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
for (i, body) in objects.into_iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for at in offsets {
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
for at in &offsets {
out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
out
}
@@ -168,6 +175,36 @@ fn a_name_tree_node_shared_by_many_kids_is_searched_once() {
assert_eq!((e.data.as_slice(), e.intact), (&b"hello"[..], true));
}
#[test]
fn pages_sharing_a_large_content_stream_are_hashed_within_a_budget() {
use std::io::Write;
// 300 pages showing one stream of 1 MiB of spaces: 300 MiB decoded, past the 256 MiB hashed.
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
z.write_all(&vec![b' '; 1 << 20]).unwrap();
let packed = z.finish().unwrap();
let pages = 300;
let kids: String = (0..pages).map(|i| format!("{} 0 R ", 7 + i)).collect();
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
"<< /Names [(a) 4 0 R] >>".to_string(),
format!("<< /Type /Filespec /UF ({EDITING_FILE}) /Desc (hash 0000000000000000) /EF << /F 5 0 R >> >>"),
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
String::new(),
];
for _ in 0..pages {
objects.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 6 0 R >>".to_string());
}
let mut stream = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", packed.len()).into_bytes();
stream.extend_from_slice(&packed);
stream.extend_from_slice(b"\nendstream");
let bytes = raw_pdf_bytes(objects.into_iter().enumerate().map(|(i, o)| if i == 5 { stream.clone() } else { o.into_bytes() }));
let start = std::time::Instant::now();
let e = editing(&bytes).unwrap();
assert_eq!((e.data.as_slice(), e.intact), (&b"hello"[..], false));
assert!(start.elapsed() < std::time::Duration::from_secs(60), "{:?}", start.elapsed());
}
#[test]
fn direct_name_tree_kids_are_all_searched() {
// Direct kids report the object holding them as their id; none of them may be taken as seen.
+12
View File
@@ -448,3 +448,15 @@ fn private_data_reads_without_a_pdf_part_that_cant_be_read() {
assert!(illustrator_data_without_pdf_part(&pdf, None).is_none(), "{odd}");
}
}
/// Art in ordinary tags nested inside a layer's sequence goes to that layer; the group is kept
/// with each level, not searched for through the tags for each object.
#[test]
fn art_deep_in_tags_inside_a_layer_reaches_it() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let content = format!("/OC /MC0 BDC {} 1 0 0 rg 10 10 30 30 re f {} EMC 0 1 0 rg 0 0 5 5 re f", "/Span BMC ".repeat(200), "EMC ".repeat(200));
let page = PdfPage { resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]), ..PdfPage::new(100.0, 100.0, &content) };
let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap();
let shapes = d.layers.iter().find(|l| l.name.as_deref() == Some("Shapes")).unwrap();
assert_eq!(colors(shapes), [Color::rgb(1.0, 0.0, 0.0)]);
}