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.
This commit is contained in:
dotCooCoo
2026-11-01 16:41:20 -08:00
parent 639c9d6ce9
commit d5849d6553
6 changed files with 142 additions and 27 deletions
+3
View File
@@ -94,6 +94,9 @@ pub(crate) struct Exporter<'a> {
layer_index: HashMap<usize, usize>,
/// The address of the layer whose form is being drawn.
in_layer: usize,
/// The paper of the page being drawn, in its drawing space: the box of each layer's form
/// covers it ([`crate::forms::layer_form`]).
paper: Rect,
/// Overprinting fills and strokes are drawn as forms marked to overprint.
pub overprint: bool,
/// An overprinting fill or stroke was drawn so.
+1
View File
@@ -45,6 +45,7 @@ impl<'a> Exporter<'a> {
layers: false,
layer_index: crate::forms::pdf_layers(doc).iter().enumerate().map(|(i, l)| (address(l.node), i)).collect(),
in_layer: 0,
paper: Rect::ZERO,
symbol_nodes: 0,
overprint: false,
overprinted: false,
+2 -1
View File
@@ -102,7 +102,8 @@ impl Exporter<'_> {
{
// A PDF layer: hidden layers and sublayers are written too (their group is off).
let enclosing = std::mem::replace(&mut self.in_layer, address(n));
crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true));
let paper = self.paper;
crate::forms::layer_form(s, i, paper, |s| self.node(s, n, page, true));
self.in_layer = enclosing;
return;
}
+2
View File
@@ -156,6 +156,8 @@ impl Writer {
s.push_mask(all);
}
if fits {
let shown = (sheet.view * sheet.place).inverse().transform_rect_bbox(paper);
ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r };
ex.children(&mut s, &doc.layers, r);
}
if page_group || cmyk_page_group {
+95 -26
View File
@@ -4,9 +4,10 @@
//! form is. Marked forms also stay apart (the writer shares identical forms). [`finish`] completes
//! them in the written file: a layer's form is drawn as the layer's optional content (`/OC …
//! BDC`), an overprinting form sets a graphics state that overprints first, and the text of an
//! invisible form is drawn in rendering mode 3 (neither filled nor stroked).
//! invisible form is drawn in rendering mode 3 (neither filled nor stroked). It also removes the
//! marks, so no form keeps an empty clip far off the page (#2181).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use craft_pdf::write::backend as krilla;
@@ -77,13 +78,30 @@ impl Mark {
/// Draw `draw` as a form of its own, marked `mark`.
pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) {
let (x, y) = mark.at();
marked(s, mark, [(x + 1.0, y), (x + 1.0, y + 1.0)], draw);
}
/// Draw `draw` (the art of layer `i` of [`pdf_layers`]) as a form of its own, marked as the
/// layer's. The mark's other two points are the corners of `paper` (the page's paper in the
/// form's drawing space), rounded outwards to whole units: [`finish`] gives the form a box that
/// covers the paper and the art.
pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rect, draw: impl FnOnce(&mut Surface)) {
let corner = |x: f64, y: f64| (x as f32, y as f32);
marked(s, Mark::Layer(i), [corner(paper.x0.floor(), paper.y0.floor()), corner(paper.x1.ceil(), paper.y1.ceil())], draw);
}
/// Draw `draw` as a form of its own, marked `mark`: an empty clip from the mark's first point
/// through `rest`.
fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce(&mut Surface)) {
let mut sb = s.stream_builder();
let mut fs = sb.surface();
let (x, y) = mark.at();
let mut pb = PathBuilder::new();
pb.move_to(x, y);
pb.line_to(x + 1.0, y);
pb.line_to(x + 1.0, y + 1.0);
for (x, y) in rest {
pb.line_to(x, y);
}
pb.close();
// A form's drawing space is its own: the mark's points are written as they are.
if let Some(p) = pb.finish() {
@@ -102,8 +120,9 @@ pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface))
const OVERPRINT_GS: &str = "VCop";
/// The prefix of the resource names of the layers' optional content groups (then the index).
const LAYER_OC: &str = "VCoc";
/// The most bytes of a form's content read to find its mark.
const HEAD: u64 = 64;
/// The most bytes of a form's content read to find its mark (a layer's mark holds four more
/// numbers of up to 11 characters each).
const HEAD: u64 = 128;
fn bad(why: &str) -> PdfError {
PdfError::Write(format!("can't write PDF layers, overprint or invisible text: {why}"))
@@ -159,6 +178,31 @@ impl Object<'_> {
_ => None,
}
}
/// The paper a layer's form's mark gives ([`layer_form`]): its left, top, right and bottom.
fn paper(&self, pdf: &[u8]) -> Option<[i64; 4]> {
let head = self.data(pdf, HEAD)?;
let mut lx = Lexer::at(&head, 0);
let mut word = || match lx.next() {
Some((Tok::Word(w), ..)) => Some(w),
_ => None,
};
let words = [(); 10].map(|_| word());
let [Some(b"q"), _, _, Some(b"m"), x0, y0, Some(b"l"), x1, y1, Some(b"l")] = words else { return None };
Some([x0.and_then(int)?, y0.and_then(int)?, x1.and_then(int)?, y1.and_then(int)?])
}
}
/// `content` (a marked form's) without its mark: what follows the mark's `Q`.
fn unmarked(content: &[u8]) -> Option<&[u8]> {
let mut lx = Lexer::at(content, 0);
while let Some((tok, _, end)) = lx.next() {
if let Tok::Word(b"Q") = tok {
let rest = content.get(end..)?;
return rest.get(rest.iter().take_while(|c| c.is_ascii_whitespace()).count()..);
}
}
None
}
/// The objects of the written file that have a dictionary, by number.
@@ -293,7 +337,7 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
return Err(bad("an overprinting form can't be read"));
};
let mut new = format!("/{OVERPRINT_GS} gs\n").into_bytes();
new.extend_from_slice(&content);
new.extend_from_slice(unmarked(&content).ok_or_else(|| bad("an overprinting form can't be read"))?);
o.rewrite(&mut patch, &new)?;
add_resources(&mut patch, res, "ExtGState", &format!("/{OVERPRINT_GS} {gs} 0 R"))?;
}
@@ -302,15 +346,19 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
for (n, _) in marks.iter().filter(|(_, m)| **m == Mark::Invisible) {
let Some(o) = objects.get(n) else { continue };
let content = o.data(&pdf, u64::MAX).ok_or_else(|| bad("an invisible text form can't be read"))?;
o.rewrite(&mut patch, &invisible_text(&content).ok_or_else(|| bad("an invisible text form can't be read"))?)?;
let text = unmarked(&content).and_then(invisible_text).ok_or_else(|| bad("an invisible text form can't be read"))?;
o.rewrite(&mut patch, &text)?;
}
}
if layers {
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
let rewritten = write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
for (n, _) in marks.iter().filter(|(_, m)| matches!(m, Mark::Layer(_))) {
if let Some(o) = objects.get(n) {
unbox(&pdf, o, &mut patch)?;
let Some(o) = objects.get(n) else { continue };
if !rewritten.contains(n) {
let content = o.data(&pdf, u64::MAX).ok_or_else(|| bad("a layer's form can't be read"))?;
o.rewrite(&mut patch, unmarked(&content).ok_or_else(|| bad("a layer's form can't be read"))?)?;
}
rebox(&pdf, o, &mut patch)?;
}
}
let pdf = patch.apply(&pdf, &xref)?;
@@ -321,7 +369,7 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
/// The optional content groups of `doc`'s layers and sublayers ([`pdf_layers`]), the uses of their
/// forms marked as their content, and the catalog's `/OCProperties`, listing sublayers under their
/// layers.
/// layers. Returns the content streams it rewrote (a layer's form among them without its mark).
fn write_layers(
pdf: &[u8],
xref: &Xref,
@@ -330,7 +378,7 @@ fn write_layers(
patch: &mut Patch,
doc: &Document,
set: &PdfSettings,
) -> Result<(), PdfError> {
) -> Result<HashSet<u32>, PdfError> {
// One group per layer, in paint order (bottom first).
let layers = pdf_layers(doc);
let mut groups: HashMap<usize, u32> = HashMap::new();
@@ -357,13 +405,14 @@ fn write_layers(
}
}
// Every content stream drawing layers' forms: the pages' and the forms'.
for o in objects.values() {
let mut rewritten = HashSet::new();
for (n, o) in objects {
let content = match o.dict.get(b"Contents") {
Some(Obj::Ref(c, _)) if o.dict.name(b"Type") == Some(b"Page") => objects.get(c),
_ if o.dict.name(b"Subtype") == Some(b"Form") => Some(o),
Some(Obj::Ref(c, _)) if o.dict.name(b"Type") == Some(b"Page") => objects.get(c).map(|o| (*c, o)),
_ if o.dict.name(b"Subtype") == Some(b"Form") => Some((*n, o)),
_ => None,
};
let (Some(content), Some(res)) = (content, resources(objects, o)) else { continue };
let (Some((number, content)), Some(res)) = (content, resources(objects, o)) else { continue };
let Some(Obj::Dict { entries, .. }) = res.get(b"XObject") else { continue };
let forms: HashMap<&[u8], usize> = entries
.iter()
@@ -379,8 +428,11 @@ fn write_layers(
continue;
}
let data = content.data(pdf, u64::MAX).ok_or_else(|| bad("a content stream can't be read"))?;
let Some((new, marked)) = mark_layers(&data, &forms) else { continue };
// A layer's form drawing its sublayers' forms loses its own mark here too.
let data = if marks.contains_key(&number) { unmarked(&data).ok_or_else(|| bad("a layer's form can't be read"))? } else { &data[..] };
let Some((new, marked)) = mark_layers(data, &forms) else { continue };
content.rewrite(patch, &new)?;
rewritten.insert(number);
let entries: String = marked.iter().filter_map(|i| Some(format!("/{LAYER_OC}{i} {} 0 R", groups.get(i)?))).collect();
add_resources(patch, res, "Properties", &entries)?;
}
@@ -399,24 +451,41 @@ fn write_layers(
let root = xref.trailer_ref(pdf, b"/Root").and_then(|n| objects.get(&n));
let Some(Object { dict: Obj::Dict { start, .. }, .. }) = root else { return Err(bad("the written PDF has no catalog")) };
patch.replace(start + 2, start + 2, format!("/OCProperties<</OCGs[{}]/D<<{config}>>>>", refs(&all)).into_bytes());
Ok(())
Ok(rewritten)
}
/// A form's box as large as a page can be: the box of a layer's form is only the bounds of its
/// art, and a reader that makes a clipping group of a form's box (as importers do) would gather a
/// layer's art and its sublayers' in one group, which no layer can hold. A box round the whole
/// page clips nothing.
/// A form's box as large as a page can be, for a layer's form whose mark gives no paper.
const UNBOXED: &[u8] = b"[-32767 -32767 32767 32767]";
/// Give the layer's form `o` the [`UNBOXED`] box.
fn unbox(pdf: &[u8], o: &Object<'_>, patch: &mut Patch) -> Result<(), PdfError> {
/// Give the layer's form `o` a box round its art and the paper its mark gives ([`layer_form`]):
/// the box of a layer's form is only the bounds of its art, and a reader that makes a clipping
/// group of a form's box (as importers do) would gather a layer's art and its sublayers' in one
/// group, which no layer can hold. A box round the whole paper clips nothing, and its bounds stay
/// those of the page and the art (#2181).
fn rebox(pdf: &[u8], o: &Object<'_>, patch: &mut Patch) -> Result<(), PdfError> {
let Obj::Dict { start, end, .. } = o.dict else { return Err(bad("a layer's form has no dictionary")) };
let dict = pdf.get(start..end).ok_or_else(|| bad("a layer's form can't be read"))?;
// The writer writes the box as an array of numbers.
let at = dict.windows(5).position(|w| w == b"/BBox").ok_or_else(|| bad("a layer's form has no box"))? + 5;
let open = at + dict.get(at..).and_then(|d| d.iter().position(|&c| c == b'[')).ok_or_else(|| bad("a layer's form box isn't an array"))?;
let close = open + dict.get(open..).and_then(|d| d.iter().position(|&c| c == b']')).ok_or_else(|| bad("a layer's form box isn't an array"))? + 1;
patch.replace(start + open, start + close, UNBOXED.to_vec());
let art: Vec<f64> = std::str::from_utf8(dict.get(open + 1..close - 1).unwrap_or_default())
.unwrap_or_default()
.split_ascii_whitespace()
.filter_map(|v| v.parse().ok())
.collect();
let new = match (o.paper(pdf), art.as_slice()) {
(Some([x0, y0, x1, y1]), &[a0, b0, a1, b1]) if [a0, b0, a1, b1].iter().all(|v| v.is_finite()) => {
let (a0, a1, b0, b1) = (a0.min(a1), a0.max(a1), b0.min(b1), b0.max(b1));
// Whole units, outwards (the art's box is written to a few decimals).
let lo = |v: i64, a: f64| (v as f64).min(a.floor());
let hi = |v: i64, a: f64| (v as f64).max(a.ceil());
format!("[{} {} {} {}]", lo(x0, a0), lo(y0, b0), hi(x1, a1), hi(y1, b1)).into_bytes()
}
(Some([x0, y0, x1, y1]), _) => format!("[{x0} {y0} {x1} {y1}]").into_bytes(),
_ => UNBOXED.to_vec(),
};
patch.replace(start + open, start + close, new);
Ok(())
}
+39
View File
@@ -339,6 +339,45 @@ fn overprinting_art_imports_as_it_was_drawn() {
}
}
/// 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}}));