Files
vectorcraft/crates/pdf/src/tests_import_layers.rs
T

411 lines
21 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.
//! Optional content groups become layers: name, visibility (the default configuration's or the
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer, and
//! art past what is read of a huge page never shows or prints a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
use crate::tests_import_fidelity::stream;
use crate::*;
/// Groups "Shapes" and "Hidden" (not printed) as the first extra objects of a file of `pages`
/// pages, the catalog entries listing them with `config` (where `{A}` and `{B}` stand for them)
/// as the default configuration, and their object numbers.
fn groups(pages: usize, config: &str) -> ([String; 2], String, [usize; 2]) {
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
let objs = [
"<< /Type /OCG /Name (Shapes) >>".to_string(),
"<< /Type /OCG /Name <FEFF00480069006400640065006E> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
];
let catalog = format!(
"/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{a} 0 R {b} 0 R] {} >> >> ",
config.replace("{A}", &format!("{a} 0 R")).replace("{B}", &format!("{b} 0 R"))
);
(objs, catalog, [a, b])
}
/// A page drawing red in "Shapes", blue in "Hidden" and green outside any group.
fn page(ids: [usize; 2]) -> PdfPage {
PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(100.0, 100.0, "/OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f")
}
}
fn file(config: &str) -> Vec<u8> {
let (objs, catalog, ids) = groups(1, config);
pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None)
}
fn names(d: &Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
/// The fill colours of a layer's art.
fn colors(l: &Node) -> Vec<Color> {
l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect()
}
fn printable(l: &Node) -> bool {
matches!(l.kind, NodeKind::Layer { printable: true, .. })
}
#[test]
fn optional_content_groups_become_layers_and_off_art_a_hidden_layer() {
let d = import(&file("/OFF [{B}]")).unwrap();
// In paint order (bottom first): the groups, then the art outside them.
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
let [shapes, hidden, rest] = [0, 1, 2].map(|i| d.layers[i].clone());
assert!(shapes.visible && printable(&shapes) && !shapes.locked);
assert_eq!(colors(&shapes), [Color::rgb(1.0, 0.0, 0.0)]);
// The group that is off is a hidden layer that still has its art; it doesn't print.
assert!(!hidden.visible && !printable(&hidden));
assert_eq!(colors(&hidden), [Color::rgb(0.0, 0.0, 1.0)]);
assert_eq!(colors(&rest), [Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn base_state_on_lists_and_locks_are_read() {
let d = import(&file("/BaseState /OFF /ON [{A}] /Locked [{A}]")).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
assert!(d.layers[0].visible && d.layers[0].locked);
assert!(!d.layers[1].visible && !d.layers[1].locked);
}
#[test]
fn without_layers_each_page_is_one_layer_of_what_shows() {
let r = import_with_report(&file("/OFF [{B}]"), &ImportOptions { layers: false, ..Default::default() }).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn pages_share_the_layers_of_their_groups() {
let (objs, catalog, ids) = groups(2, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids), page(ids)], &[&objs[0], &objs[1]], &catalog, None);
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1", "Page 2"]);
assert_eq!(colors(&d.layers[0]).len(), 2, "both pages' art");
assert_eq!(colors(&d.layers[1]).len(), 2);
// The second page's art sits on its artboard.
let x = d.layers[0].children().unwrap()[1].geometric_bounds().unwrap().x0;
assert!(x > d.artboards[1].rect.x0, "{x}");
}
#[test]
fn groups_marked_inside_forms_and_clips_reach_their_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Properties << /MC1 {} 0 R >> >>", ids[1]),
"/OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC",
);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R >> /XObject << /X0 {form_no} 0 R >>", ids[0]),
..PdfPage::new(100.0, 100.0, "q 0 0 25 25 re W n /OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC Q /X0 Do")
};
let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None)).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden"]);
let shapes = &d.layers[0].children().unwrap()[0];
assert!(matches!(shapes.kind, NodeKind::Group { clip: true, .. }), "the clip group goes with its art");
assert!(!d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
#[test]
fn a_form_hidden_by_its_own_group_stays_hidden() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC {} 0 R", ids[1]), "0 0 1 rg 50 50 30 30 re f");
let page = PdfPage { resources: format!("/XObject << /X0 {form_no} 0 R >>"), ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f /X0 Do") };
let r = import_with_report(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None), &ImportOptions::default()).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0)], "the hidden form isn't shown");
assert!(r.warnings.iter().any(|w| w.contains("hidden layers")), "{:?}", r.warnings);
}
#[test]
fn an_encrypted_file_keeps_its_hidden_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, Some("pw"));
let r = import_with_report(&bytes, &ImportOptions { password: Some("pw".into()), ..Default::default() }).unwrap();
let d = r.document;
assert_eq!(d.layers.len(), 3, "{:?}", r.warnings);
assert!(d.layers[0].visible && !d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
/// A page drawing `before`, white and red squares in "Shapes", then `after`; `ai`: with an
/// editor's private data (a PDF-compatible `.ai`).
fn page_fill(before: &str, after: &str, ai: bool) -> Document {
let (objs, catalog, ids) = groups(1, "");
let page = PdfPage {
resources: format!(
"/Properties << /MC0 {} 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> /ColorSpace << /W [/Separation /White /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 0] /N 1 >>] >>",
ids[0]
),
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(200.0, 100.0, &format!("{before} /OC /MC0 BDC 1 1 1 rg 20 20 60 60 re f 1 0 0 rg 120 20 60 60 re f EMC {after}"))
};
import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap()
}
/// A file with an editor's private data says that art off its artboards isn't in the part read
/// (#472); a plain PDF has nothing to say.
#[test]
fn an_ai_file_notes_that_its_art_off_the_artboards_is_not_read() {
for ai in [false, true] {
let page = PdfPage {
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
assert_eq!(r.warnings, if ai { vec![crate::import::OFF_ARTBOARD_NOTE.to_string()] } else { vec![] });
}
}
fn path_count(d: &Document) -> usize {
let mut n = 0;
d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. })));
n
}
#[test]
fn an_ai_files_white_page_outside_its_layers_is_not_art() {
// RGB, grey and CMYK white, the rectangle drawn either way round.
for fill in ["1 1 1 rg 0 0 200 100 re f", "1 g 0 100 200 -100 re f", "0 0 0 0 k 0 0 200 100 re f"] {
let d = page_fill(fill, "", true);
assert_eq!(names(&d), ["Shapes"], "{fill}");
assert_eq!(path_count(&d), 2, "{fill}");
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0)], "{fill}");
}
}
#[test]
fn a_page_fill_that_is_art_stays() {
// A plain PDF's white background is art, as are an .ai's fills that aren't a plain white
// page under its layers: grey, smaller or larger than the page, stroked, translucent or a
// white ink.
let page = "1 1 1 rg 0 0 200 100 re f";
let kept = [
(page, false),
("0.9 g 0 0 200 100 re f", true),
("1 g 10 10 180 80 re f", true),
("1 g -20 -20 240 140 re f", true),
("1 g 0 G 0 0 200 100 re B", true),
("/GS0 gs 1 g 0 0 200 100 re f", true),
("/W cs 1 scn 0 0 200 100 re f", true),
];
for (fill, ai) in kept {
let d = page_fill(fill, "", ai);
assert_eq!(names(&d), ["Page 1", "Shapes"], "{fill}");
assert_eq!(path_count(&d), 3, "{fill}");
}
// Painted over the layers.
let d = page_fill("", page, true);
assert_eq!(names(&d), ["Shapes", "Page 1"]);
assert_eq!(path_count(&d), 3);
}
/// A page drawing a red, a blue and a green square in groups `defs` (as `/MC0`–`/MC2`), the
/// catalog listing them with default configuration `config`, plus `more` objects; in `config` and
/// `more`, `{0}`–`{2}` stand for the groups.
fn three_groups(defs: [&str; 3], more: &[&str], config: &str) -> Vec<u8> {
let refs = [0, 1, 2].map(|i| format!("{} 0 R", first_extra(1) + i));
let fill = |s: &str| (0..3).fold(s.to_string(), |s, i| s.replace(&format!("{{{i}}}"), &refs[i]));
let content = "/OC /MC0 BDC 1 0 0 rg 10 10 20 20 re f EMC /OC /MC1 BDC 0 0 1 rg 40 40 20 20 re f EMC /OC /MC2 BDC 0 1 0 rg 70 70 20 20 re f EMC";
let page = PdfPage { resources: fill("/Properties << /MC0 {0} /MC1 {1} /MC2 {2} >>"), ..PdfPage::new(100.0, 100.0, content) };
let catalog = format!("/OCProperties << /OCGs [{}] /D << {} >> >> ", refs.join(" "), fill(config));
let more: Vec<String> = more.iter().map(|m| fill(m)).collect();
let objs: Vec<&str> = defs.iter().copied().chain(more.iter().map(String::as_str)).collect();
pdf_with_catalog(&[page], &objs, &catalog, None)
}
fn ocg(name: &str, usage: &str) -> String {
format!("<< /Type /OCG /Name ({name}) {usage} >>")
}
fn visibility(layers: &[std::sync::Arc<Node>]) -> Vec<bool> {
layers.iter().map(|l| l.visible).collect()
}
#[test]
fn a_view_state_that_is_off_hides_the_layer_with_its_art() {
let off = ocg("Off", "/Usage << /View << /ViewState /OFF >> >>");
// With an /AS entry for viewing, the view state wins over the default configuration.
let on = ocg("On", "/Usage << /View << /ViewState /ON >> >>");
let bytes = three_groups([&ocg("Plain", ""), &off, &on], &[], "/OFF [{2}] /AS [<< /Event /View /Category [/View] /OCGs [{1} {2}] >>]");
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Plain", "Off", "On"]);
assert_eq!(visibility(&d.layers), [true, false, true]);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)], "the art the default configuration hides is read");
// Without one, a view state that is off still hides the layer.
let d = import(&three_groups([&ocg("Plain", ""), &off, &ocg("Other", "")], &[], "")).unwrap();
assert_eq!(visibility(&d.layers), [true, false, true]);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
#[test]
fn nested_groups_become_sublayers_and_hidden_ones_keep_their_art() {
// The order as an object of its own (as Illustrator writes it): "Top" holds "Sub", which is
// off; a label's groups stay at the level of the label.
let order = first_extra(1) + 3;
let bytes = three_groups(
[&ocg("Top", ""), &ocg("Sub", ""), &ocg("Other", "")],
&["[{0} [{1}] [(Label) {2}]]"],
&format!("/OFF [{{1}}] /Order {order} 0 R"),
);
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Top", "Other"]);
assert_eq!(visibility(&d.layers), [true, true]);
let top = d.layers[0].children().unwrap();
assert_eq!(top.len(), 2, "its art, then its sublayer");
assert_eq!(top[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(1.0, 0.0, 0.0)));
let sub = &top[1];
assert!(matches!(sub.kind, NodeKind::Layer { .. }) && sub.name.as_deref() == Some("Sub"));
assert!(!sub.visible, "the sublayer that is off is hidden");
assert_eq!(colors(sub), [Color::rgb(0.0, 0.0, 1.0)], "with its art");
assert_eq!(d.layer_color(sub.id), d.layer_color(d.layers[0].id), "a sublayer takes its layer's colour");
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn a_sublayer_that_paints_first_brings_its_parent_layer() {
// "Sub" paints first; "Top", its parent, paints its own art last.
let bytes = three_groups([&ocg("Sub", ""), &ocg("Other", ""), &ocg("Top", "")], &[], "/Order [{2} [{0}] {1}]");
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Top", "Other"]);
let top = d.layers[0].children().unwrap();
assert_eq!(top.len(), 2);
assert_eq!(top[0].name.as_deref(), Some("Sub"));
assert_eq!(colors(&top[0]), [Color::rgb(1.0, 0.0, 0.0)]);
assert_eq!(top[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.0, 1.0, 0.0)));
}
#[test]
fn hostile_layer_orders_stay_bounded() {
// A group listed twice, under itself, and an order nested far deeper than layers go.
let deep = format!("{}{{2}}{}", "[".repeat(40), "]".repeat(40));
let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]");
let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap();
assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer");
assert_eq!(d.layers[0].children().unwrap()[1].name.as_deref(), Some("B"));
let mut count = 0;
d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Layer { .. })));
assert_eq!(count, 3, "each group is one layer");
}
#[test]
fn thousands_of_layers_hidden_ones_included_all_come_in() {
const N: usize = 3000;
let first = first_extra(1);
let objs: Vec<String> = (0..N).map(|i| ocg(&format!("L{i}"), "")).collect();
let refs: Vec<String> = (0..N).map(|i| format!("{} 0 R", first + i)).collect();
let props: String = refs.iter().enumerate().map(|(i, r)| format!("/M{i} {r} ")).collect();
let content: String = (0..N).map(|i| format!("/OC /M{i} BDC {} {} 1 1 re f EMC ", i % 100, i / 100)).collect();
let off: Vec<&str> = refs.iter().step_by(2).map(String::as_str).collect();
let catalog = format!("/OCProperties << /OCGs [{}] /D << /OFF [{}] >> >> ", refs.join(" "), off.join(" "));
let page = PdfPage { resources: format!("/Properties << {props}>>"), ..PdfPage::new(100.0, 100.0, &content) };
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let d = import(&pdf_with_catalog(&[page], &objs, &catalog, None)).unwrap();
assert_eq!(d.layers.len(), N);
assert!(d.layers.iter().enumerate().all(|(i, l)| l.visible == (i % 2 == 1) && l.children().is_some_and(|c| c.len() == 1)));
}
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> Result<ImportReport, PdfError> {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
// (On this thread: the limit is this thread's.)
let r = crate::import::read(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r
}
#[test]
fn art_past_the_scan_limit_is_rejected_with_hidden_layers() {
// "Shapes" draws 100 operators before its square, then "Hidden" (off, not printed) its own.
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let filler = "q Q ".repeat(50);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(
100.0,
100.0,
&format!("/OC /MC0 BDC {filler}1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f"),
)
};
let bytes = pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None);
// Within the limit, every group is told apart.
assert_eq!(names(&import(&bytes).unwrap()), ["Shapes", "Hidden", "Page 1"]);
let error = import_with_max_ops(&bytes, 60).unwrap_err();
assert!(error.to_string().contains("no document was imported"));
}
#[test]
fn art_past_the_scan_limit_is_rejected_with_shown_layers() {
let bytes = three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], "");
assert_eq!(names(&import(&bytes).unwrap()), ["A", "B", "C"]);
let error = import_with_max_ops(&bytes, 4).unwrap_err();
assert!(error.to_string().contains("no document was imported"));
}
/// Art reaching past the page keeps a clip to it in a plain PDF (as viewers show it), but not in
/// a PDF-compatible `.ai`, whose art crossing the artboard edge opens whole and editable (#646).
#[test]
fn art_past_the_page_is_clipped_in_a_pdf_but_not_in_an_ai_file() {
for ai in [false, true] {
let page = PdfPage {
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(100.0, 100.0, "0 0 100 100 re W n 1 0 0 rg 50 50 200 200 re f")
};
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
let mut clips = 0;
r.document.walk(|n| clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. })));
let past = r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE);
assert_eq!((clips, past), if ai { (0, false) } else { (1, true) }, "ai: {ai}: {:?}", r.warnings);
let art = r.document.art_bounds().unwrap();
assert_eq!(art.x1 > 100.5, ai, "ai: {ai}: {art:?}");
}
}
/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order.
#[test]
fn an_ai_files_private_data_is_its_streams_joined() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
let page = |ai: bool| PdfPage {
entries: if ai {
format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {a} 0 R /AIPrivateData2 {b} 0 R /NumBlock 2 >> >> >>")
} else {
String::new()
},
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let (one, two) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"));
let ai = pdf_with_catalog(&[page(true)], &[&one, &two], "", None);
assert_eq!(illustrator_data(&ai, None).unwrap(), b"%AI24_ZStandard_Data first second");
// A PDF without them has none; neither has bytes that aren't a PDF.
assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none());
assert!(illustrator_data(b"not a pdf", None).is_none());
// Only a plain or a deflated stream is read: a chain of filters is refused, not run unbounded.
let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>");
assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none());
}
/// A PDF saved with the editing data has its streams under `AIPDFPrivateData` keys instead (#868);
/// a file with both kinds is read from its `AIPrivateData` streams.
#[test]
fn a_pdfs_private_data_is_its_pdf_streams_joined() {
let (a, b, c) = (first_extra(1), first_extra(1) + 1, first_extra(1) + 2);
let page = |keys: String| PdfPage {
entries: format!("/PieceInfo << /Illustrator << /Private << {keys} /NumBlock 2 >> >> >>"),
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let (one, two, other) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"), stream("", "%AI24_ZStandard_Data other"));
let pdf = pdf_with_catalog(&[page(format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R"))], &[&one, &two], "", None);
assert_eq!(illustrator_data(&pdf, None).unwrap(), b"%AI24_ZStandard_Data first second");
let both = format!("/AIPDFPrivateData1 {a} 0 R /AIPDFPrivateData2 {b} 0 R /AIPrivateData1 {c} 0 R");
assert_eq!(illustrator_data(&pdf_with_catalog(&[page(both)], &[&one, &two, &other], "", None), None).unwrap(), b"%AI24_ZStandard_Data other");
}