diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 9b5600829..a8dd48628 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -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 { let xref = pdf.xref(); let catalog = xref.get::>(xref.root_id())?; let tree = catalog.get::>(b"Names")?.get::>(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::>(b"EF")?; let stream = files.get::>(b"F").or_else(|| files.get::>(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 { /// 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) -> Option> { +fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet, xref: &'a XRef) -> Option> { if let Some(names) = node.get::>(b"Names") { let mut items = names.iter::>(); 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>(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::>()).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::>(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::>(b"Contents") { - h = fnv(h, stream.decoded().as_deref().unwrap_or_default()); + h = content(h, &stream); } else if let Some(array) = contents.get::>(b"Contents") { for stream in array.iter::>() { - if let Ok(data) = stream.decoded() { - h = fnv(fnv(h, &data), b" "); - } + h = fnv(content(h, &stream), b" "); } } } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index c75a28a60..931944384 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -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 { + 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; } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index f0049787f..86a4a89a3 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -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 { - self.marked.iter().rev().find_map(|g| *g) + pub(super) fn current_group(&self) -> Option { + self.marked.last().and_then(|(_, current)| *current) } /// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index f89deaaf9..84d8885a0 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -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(); diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index f2d974f15..abf7499d0 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -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>, + /// 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, Option)>, 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, diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index 9025f342f..7c8499642 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -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, Vec) { 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 = 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, 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::>().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. diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index b2142ace3..8fb57198f 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -116,7 +116,7 @@ fn hex(data: &[u8]) -> Vec { } /// `data` decoded from base-85 (white space skipped, `z` for four zeros, up to `~>`). -fn base85(data: &[u8]) -> Vec { +fn base85(data: &[u8], max: usize) -> Vec { let (mut out, mut group) = (vec![], vec![]); let flush = |group: &mut Vec, out: &mut Vec| { let n = group.len(); @@ -128,6 +128,10 @@ fn base85(data: &[u8]) -> Vec { 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 { } /// 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>, 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(); diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 91ee3d5f2..406ae08b8 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -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 = (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]); +} diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index ab70e4521..9e012e330 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -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 { + 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"); +} diff --git a/crates/pdf/src/tests/editing.rs b/crates/pdf/src/tests/editing.rs index 69dbbc538..9cc77ff68 100644 --- a/crates/pdf/src/tests/editing.rs +++ b/crates/pdf/src/tests/editing.rs @@ -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 { + 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>) -> Vec { 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. diff --git a/crates/pdf/src/tests/import_layers.rs b/crates/pdf/src/tests/import_layers.rs index 8ddeb2f83..86e1a88fb 100644 --- a/crates/pdf/src/tests/import_layers.rs +++ b/crates/pdf/src/tests/import_layers.rs @@ -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)]); +}