Files
vectorcraft/crates/pdf/src/tests/layers.rs
T
dotCooCoo d5849d6553 Fix #2181: PDF layer forms get real boxes and lose their off-page marks
With Create PDF layers on, every layer's form XObject had
/BBox [-32767 -32767 32767 32767] and started with an empty clip at
x = -32000 that only marked the form while the file was written. Other
apps read that as art about 23 m across, and the reporters could not move
or place the imported layers.

- The marks of layer, overprint and invisible-text forms are removed when
  the file is finished.
- A layer's form gets a box round its art and the page's paper, in whole
  units. Since the box still covers the page, PDF import skips it as a
  clip, so sublayers still import as sublayers.
2026-11-01 16:41:20 -08:00

386 lines
18 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.
//! Save PDF › Create PDF layers (an optional content group per top-level layer, with its
//! visibility, print state and lock) and Advanced › Overprint (a graphics state that overprints
//! for the overprinting fills and strokes).
use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, LayerColor, Node, NodeKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use crate::*;
const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 };
const GREEN: Color = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 };
const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 };
fn rect(d: &mut Document, x: f64, c: Color) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
}
/// Layers, bottom first: "Back" (red), "Hidden" (green; hidden and locked), "Notes" (blue; not
/// printed) and a template layer (grey).
fn layered() -> Document {
let mut d = Document::new(200.0, 100.0);
d.layers.clear();
for (i, (name, c)) in [("Back", RED), ("Hidden", GREEN), ("Notes", BLUE), ("Template", Color::gray(0.5))].into_iter().enumerate() {
let art = rect(&mut d, 10.0 + 40.0 * i as f64, c);
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(i as u8));
(l.visible, l.locked) = (name != "Hidden", name == "Hidden");
if let NodeKind::Layer { children, printable, template, .. } = &mut l.kind {
children.push(art.into());
*printable = name != "Notes";
*template = name == "Template";
}
d.layers.push(l.into());
}
d
}
fn export(d: &Document, v: Value) -> ExportReport {
let settings: PdfSettings = serde_json::from_value(v).unwrap();
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
}
/// The groups of `refs` (an array of the catalog's optional content properties) by name.
fn names(xref: &craft_pdf::syntax::xref::XRef, refs: Option<Array<'_>>) -> Vec<String> {
let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>()).unwrap_or_default();
ids.into_iter()
.map(|id| {
let g = xref.get::<Dict<'_>>(id).unwrap();
String::from_utf8(g.get::<craft_pdf::syntax::object::String<'_>>(b"Name").unwrap().as_bytes().to_vec()).unwrap()
})
.collect()
}
/// The catalog's `/OCProperties` of `bytes` (opened with `password`), if any.
fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option<Dict<'_>>, &craft_pdf::syntax::xref::XRef)) {
let file = crate::pages::open(bytes, password).unwrap();
let xref = file.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
f(catalog.get::<Dict<'_>>(b"OCProperties"), xref);
}
/// The layers of an import: name, visible, printable, locked and fill colours of their art.
fn layers(d: &Document) -> Vec<(String, bool, bool, bool, Vec<Color>)> {
d.layers
.iter()
.map(|l| {
let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. });
let colors = l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect();
(l.name.clone().unwrap_or_default(), l.visible, printable, l.locked, colors)
})
.collect()
}
#[test]
fn create_layers_writes_a_group_per_layer_with_its_states() {
let d = layered();
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
oc_properties(&r.bytes, None, |props, xref| {
let props = props.expect("/OCProperties");
// Template layers aren't exported; the groups are in paint order, listed top first.
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
let config = props.get::<Dict<'_>>(b"D").unwrap();
assert_eq!(names(xref, config.get(b"Order")), ["Notes", "Hidden", "Back"]);
assert_eq!(names(xref, config.get(b"OFF")), ["Hidden"], "hidden layers are off");
assert_eq!(names(xref, config.get(b"Locked")), ["Hidden"]);
assert!(config.get::<craft_pdf::syntax::object::String<'_>>(b"Name").is_some());
let auto = config.get::<Array<'_>>(b"AS").unwrap().iter::<Dict<'_>>().next().unwrap();
assert_eq!(auto.get::<Name<'_>>(b"Event").unwrap().as_ref(), b"Print");
let print_state = |name: &str| {
let ids =
props.get::<Array<'_>>(b"OCGs").unwrap().raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>();
let g = ids
.into_iter()
.map(|id| xref.get::<Dict<'_>>(id).unwrap())
.find(|g| g.get::<craft_pdf::syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
let usage = g.unwrap().get::<Dict<'_>>(b"Usage").unwrap();
usage.get::<Dict<'_>>(b"Print").unwrap().get::<Name<'_>>(b"PrintState").unwrap().as_ref().to_vec()
};
assert_eq!(print_state("Notes"), b"OFF", "non-printing layers aren't printed");
assert_eq!(print_state("Back"), b"ON");
});
// Each layer's art is its group's optional content.
let text = String::from_utf8_lossy(&r.bytes);
assert_eq!(text.matches(" BDC ").count(), 3, "{text}");
assert!(text.contains("/OC /VCoc0 BDC /x") && text.contains("/Properties<<"));
}
#[test]
fn pdf_layers_import_back_as_layers() {
let d = layered();
for password in [None, Some("secret")] {
let mut set = json!({"createLayers": true});
if let Some(p) = password {
set["security"] = json!({"openPassword": p});
}
let bytes = export(&d, set).bytes;
let opts = ImportOptions { password: password.map(str::to_string), ..Default::default() };
let back = import_with_report(&bytes, &opts).unwrap().document;
assert_eq!(
layers(&back),
[
("Back".to_string(), true, true, false, vec![RED]),
("Hidden".to_string(), false, true, true, vec![GREEN]),
("Notes".to_string(), true, false, false, vec![BLUE]),
],
"password {password:?}"
);
}
}
/// The rows of `nodes` in paint order, depth first: `depth name visible` for layers, `depth fill`
/// for art.
fn tree(nodes: &[std::sync::Arc<Node>], depth: usize, out: &mut Vec<String>) {
for n in nodes {
match &n.kind {
NodeKind::Layer { children, .. } => {
out.push(format!("{depth} {} {}", n.name.clone().unwrap_or_default(), n.visible));
tree(children, depth + 1, out);
}
_ => out.push(format!("{depth} {:?}", n.appearance.fill().and_then(|f| f.paint.color()))),
}
}
}
/// Sublayers are PDF layers listed under their layer's, hidden ones off with their art, and they
/// import back as sublayers (#372: other apps read a `.ai` file's PDF part).
#[test]
fn sublayers_are_nested_pdf_layers_and_import_back() {
let mut d = Document::new(200.0, 100.0);
d.layers.clear();
let layer = |d: &mut Document, name: &str, visible: bool, children: Vec<Node>| {
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0));
l.visible = visible;
if let NodeKind::Layer { children: c, .. } = &mut l.kind {
c.extend(children.into_iter().map(std::sync::Arc::new));
}
l
};
let deep = vec![rect(&mut d, 90.0, GREEN)];
let deep = layer(&mut d, "Deep", true, deep);
let sub = vec![rect(&mut d, 50.0, BLUE), deep];
let sub = layer(&mut d, "Sub", false, sub);
let main = vec![rect(&mut d, 10.0, RED), sub];
let main = layer(&mut d, "Main", true, main);
d.layers.push(main.into());
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
oc_properties(&r.bytes, None, |props, xref| {
let props = props.expect("/OCProperties");
assert_eq!(names(xref, props.get(b"OCGs")), ["Main", "Sub", "Deep"]);
assert_eq!(names(xref, props.get::<Dict<'_>>(b"D").unwrap().get(b"OFF")), ["Sub"]);
});
let text = String::from_utf8_lossy(&r.bytes);
let order = text.split("/Order").nth(1).and_then(|o| o.split("]/").next()).unwrap().to_string();
assert_eq!(order.matches('[').count(), 3, "each layer's sublayers in an array after it: {order}");
let mut got = vec![];
tree(&import(&r.bytes).unwrap().layers, 0, &mut got);
// Paint order, as it was.
let fill = |depth, c: Color| format!("{depth} {:?}", Some(c));
assert_eq!(got, ["0 Main true".to_string(), fill(1, RED), "1 Sub false".into(), fill(2, BLUE), "2 Deep true".into(), fill(3, GREEN)]);
}
#[test]
fn identical_layers_stay_apart() {
let mut d = Document::new(200.0, 100.0);
d.layers.clear();
for name in ["One", "Two"] {
let art = rect(&mut d, 10.0, RED);
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0));
if let NodeKind::Layer { children, .. } = &mut l.kind {
children.push(art.into());
}
d.layers.push(l.into());
}
let back = import(&export(&d, json!({"createLayers": true})).bytes).unwrap();
let got: Vec<(String, usize)> = back.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().unwrap().len())).collect();
assert_eq!(got, [("One".to_string(), 1), ("Two".to_string(), 1)]);
}
#[test]
fn without_pdf_layers_the_layers_are_plain_page_content() {
let mut d = layered();
for (v, warning) in [
(json!({}), None),
(json!({"createLayers": true, "compatibility": "1.4"}), Some("1.5")),
(json!({"createLayers": true, "compatibility": "1.5"}), None),
] {
let r = export(&d, v.clone());
let mut has = false;
oc_properties(&r.bytes, None, |props, _| has = props.is_some());
assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}");
match warning {
Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings),
None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings),
}
}
// A knockout page group draws objects, not layers.
d.page_knockout = true;
let r = export(&d, json!({"createLayers": true}));
oc_properties(&r.bytes, None, |props, _| assert!(props.is_none()));
assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings);
}
#[test]
fn pdf_a_layers_have_no_automatic_states() {
let r = export(&layered(), json!({"standard": "pdfA2b", "createLayers": true}));
oc_properties(&r.bytes, None, |props, _| {
let config = props.unwrap().get::<Dict<'_>>(b"D").unwrap();
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<craft_pdf::syntax::object::String<'_>>(b"Name").is_some());
});
}
#[test]
fn pdf_x4_keeps_pdf_layers_and_x1a_refuses_them() {
let d = layered();
let r = export(&d, json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true}));
assert!(r.bytes.starts_with(b"%PDF-1.6"));
oc_properties(&r.bytes, None, |props, xref| {
let props = props.expect("/OCProperties");
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
assert!(props.get::<Dict<'_>>(b"D").unwrap().get::<Array<'_>>(b"AS").is_none());
});
let set: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).unwrap();
assert!(matches!(export_with_report(&d, &PdfOptions { settings: set, ..Default::default() }), Err(PdfError::BadSetting(_))));
}
/// The painted paths of `d`: their bounds' corner and colour, in order.
fn leaves(d: &Document) -> Vec<(i32, i32, Option<[u8; 4]>)> {
let mut out = vec![];
d.walk(|n| {
if let Some(b) = n.path_data().and_then(|p| p.bounds()) {
let paint = n.appearance.fill().map(|f| f.paint.clone()).or_else(|| n.appearance.stroke().map(|s| s.paint.clone()));
out.push((b.x0.round() as i32, b.y0.round() as i32, paint.and_then(|p| p.color()).map(|c| c.to_rgba8(1.0))));
}
});
out.sort_by_key(|l| (l.0, l.1));
out
}
#[test]
fn layered_art_imports_as_it_was_drawn() {
let mut d = layered();
d.layers = d.layers.iter().map(|l| Node { visible: true, ..(**l).clone() }.into()).collect();
let plain = leaves(&import(&export(&d, json!({"includeNonPrinting": true})).bytes).unwrap());
assert_eq!(plain.len(), 3);
assert_eq!(leaves(&import(&export(&d, json!({"createLayers": true})).bytes).unwrap()), plain, "the marks draw nothing");
}
/// A rectangle whose fill overprints, one whose stroke does, a plain one and type whose
/// characters' fill overprints, filled with `fill` (the overprinting rectangle).
fn overprinting(fill: Color) -> Document {
let mut d = Document::new(300.0, 100.0);
let l = d.layers[0].id;
let mut a = rect(&mut d, 10.0, fill);
if let Some(f) = a.appearance.fill_mut() {
f.overprint = true;
}
let mut b =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Appearance::basic(Paint::None, Paint::solid(BLUE), 2.0));
if let Some(s) = b.appearance.stroke_mut() {
s.overprint = true;
}
let c = rect(&mut d, 90.0, GREEN);
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), overprint_fill: true, ..Default::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(130.0, 40.0), "Ink", style))));
for (i, n) in [a, b, c, t].into_iter().enumerate() {
d.insert(Some(l), i, n).unwrap();
}
d
}
/// The uncompressed export of `d` with `v`: the overprinting graphics states it defines, and the
/// forms that set one.
fn overprints(d: &Document, v: Value) -> (usize, usize) {
let mut v = v;
v["compression"] = json!({"compressText": false});
let r = export(d, v);
assert!(r.warnings.iter().all(|w| !w.contains("overprint")), "{:?}", r.warnings);
let text = String::from_utf8_lossy(&r.bytes).into_owned();
(text.matches("/Type/ExtGState/OP true/op true/OPM 1").count(), text.matches("stream\n/VCop gs\n").count())
}
#[test]
fn overprint_preserve_writes_an_overprinting_graphics_state() {
let d = overprinting(RED);
assert_eq!(overprints(&d, json!({})), (1, 3), "the fill, the stroke and the characters' fill");
assert_eq!(overprints(&d, json!({"advanced": {"overprint": "discard"}})), (0, 0));
assert_eq!(overprints(&overprinting(RED), json!({"createLayers": true})), (1, 3), "with PDF layers too");
// Nothing overprints: no graphics state.
assert_eq!(overprints(&layered(), json!({})), (0, 0));
}
#[test]
fn white_overprint_is_discarded_as_the_document_setup_says() {
let mut d = overprinting(Color::WHITE);
assert!(d.setup.discard_white_overprint);
assert_eq!(overprints(&d, json!({})).1, 2, "the white fill knocks out");
d.setup.discard_white_overprint = false;
assert_eq!(overprints(&d, json!({})).1, 3);
}
#[test]
fn pdf_a_overprint_mode_is_0() {
let r = export(&overprinting(RED), json!({"standard": "pdfA2b", "compression": {"compressText": false}}));
let text = String::from_utf8_lossy(&r.bytes);
assert!(text.contains("/OP true/op true/OPM 0") && !text.contains("/OPM 1"));
}
#[test]
fn overprinting_art_imports_as_it_was_drawn() {
let d = overprinting(RED);
// What is drawn (a form's bounds may come back as an unpainted clipping path).
let painted = |bytes: &[u8]| leaves(&import(bytes).unwrap()).into_iter().filter(|l| l.2.is_some()).collect::<Vec<_>>();
let plain = painted(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes);
for v in [json!({}), json!({"createLayers": true})] {
assert_eq!(painted(&export(&d, v.clone()).bytes), plain, "{v}: the marks draw nothing");
}
}
/// The `/BBox` of each form of the uncompressed `bytes`, in the order written.
fn form_boxes(bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes);
text.split("/Subtype/Form").skip(1).filter_map(|d| Some(d.split("/BBox[").nth(1)?.split(']').next()?.to_string())).collect()
}
/// #2181: a layer's form has a box round the paper and its art, not one 23 m across, and no
/// form (a layer's, an overprinting one or invisible text's) keeps the empty clip far off the
/// page that marked it while the file was written.
#[test]
fn layer_forms_have_real_boxes_and_no_marks() {
let mut d = overprinting(RED);
// Invisible text (neither fill nor stroke) and art across the paper's left edge.
let l = d.layers[0].id;
let style = CharStyle { size: 24.0, fill: Paint::None, ..Default::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(200.0, 40.0), "Hid", style))));
let past = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-50.0, 10.0, 5.0, 40.0)), Appearance::basic(Paint::solid(RED), Paint::None, 0.0));
d.insert(Some(l), 0, t).unwrap();
d.insert(Some(l), 0, past).unwrap();
let mut sub = Node::layer(d.alloc_id(), "Sub", LayerColor::Preset(1));
if let NodeKind::Layer { children, .. } = &mut sub.kind {
children.push(rect(&mut d, 250.0, BLUE).into());
}
d.insert(Some(l), 0, sub).unwrap();
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
let text = String::from_utf8_lossy(&r.bytes);
for mark in ["-32000 ", "-32001 ", "-32002 ", "32767"] {
assert!(!text.contains(mark), "{mark} is in the file: {text}");
}
assert!(text.contains("3 Tr") && text.contains("/VCop gs"), "invisible text and overprint are still written: {text}");
// The layer's form reaches the art across the left edge; its sublayer's covers the paper.
let boxes = form_boxes(&r.bytes);
assert!(boxes.contains(&"-50 0 300 100".to_string()) && boxes.contains(&"0 0 300 100".to_string()), "{boxes:?}");
// It still imports as it was drawn: a layer holding its sublayer, no clipping group.
let mut got = vec![];
tree(&import(&r.bytes).unwrap().layers, 0, &mut got);
assert!(got.contains(&"1 Sub true".to_string()) && got.contains(&format!("2 {:?}", Some(BLUE))), "{got:?}");
}
#[test]
fn pdf_x1a_keeps_overprint() {
let r = export(&overprinting(RED), json!({"standard": "pdfX1a", "compatibility": "1.4", "compression": {"compressText": false}}));
assert!(r.bytes.starts_with(b"%PDF-1.3") && String::from_utf8_lossy(&r.bytes).contains("/OP true/op true/OPM 1"));
}