mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 11:53:49 +00:00
Prepare grouped text with Ungroup's own form expansion
text/forms/expand.rs was a drifted copy of ungroup.rs. It is gone: text preparation now opens each placement with crate::ungroup, so Ungroup and preparation share one implementation. What the copy added is now in ungroup.rs: a Refusal that says whether the form itself or only this placement is the reason (intrinsic) and whether a rename alone was the problem (renaming), the shows_text result, merges_without_renaming, refusals for unreadable /Resources and /Group entries and arrays with a non-number, a cap on resource counts and q nesting, a total size for pattern copies, and ending an unfinished path with the form. Ungroup's newer behaviour stays: names are read from the operand the renderer uses, so a surplus operand is renamed correctly rather than refused; default colour spaces are compared with the page's; and a group blending in DeviceRGB on a page that names no blending space is drawn inline, as Ungroup does, rather than refused as the copy did. What a form draws is now read within one decode allowance shared with the form itself, so a crafted group can no longer make one Ungroup decode 64 MiB for each of 1024 nested forms. A group whose content can't be checked is refused; for a form without a group, blend modes inside don't matter, and content that can't be checked only means preparation can't tell whether it has text (shows_text is None), which it reports.
This commit is contained in:
@@ -1670,8 +1670,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
|
||||
let space = crate::ungroup::page_blend_space(doc, &p.dict);
|
||||
let (inline, mut merged, patterns) =
|
||||
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?;
|
||||
// One allowance for the form and everything it draws.
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0);
|
||||
let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } =
|
||||
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix), &mut budget)
|
||||
.map_err(|r| r.error)?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, copy) in patterns {
|
||||
|
||||
+58
-43
@@ -11958,6 +11958,27 @@ fn ungroup_reads_each_nested_form_once_and_within_a_ceiling() {
|
||||
assert!(matches!(many, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{many:?}");
|
||||
}
|
||||
|
||||
/// The forms a group draws are read within one allowance shared with the group itself, not 64 MiB
|
||||
/// each: 70 forms that each inflate to 1 MiB are past it, so the group can't be checked and is
|
||||
/// refused instead of being decoded 70 MiB deep (or, with 1024 of them, 64 GiB).
|
||||
#[test]
|
||||
fn ungroup_reads_what_a_group_draws_within_one_allowance() {
|
||||
let r = ungroup_art(&(0..70).map(|i| format!("/F{i} Do ")).collect::<String>().into_bytes(), true, |doc| {
|
||||
let mut xo = Dict::new();
|
||||
for i in 0..70 {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
|
||||
xo.set(format!("F{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(d, &vec![b' '; 1 << 20])))));
|
||||
}
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
res
|
||||
});
|
||||
assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("can't be checked")), "{r:?}");
|
||||
}
|
||||
|
||||
/// A transparency group that draws a nested form whose content can't be read is refused: what
|
||||
/// it blends can't be known, so it isn't guessed.
|
||||
#[test]
|
||||
@@ -12722,39 +12743,32 @@ fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_i
|
||||
}
|
||||
}
|
||||
|
||||
/// A form whose content gives an operator a surplus operand (`0 /Inner Do`) is left grouped. The
|
||||
/// renderer draws the last operand, while the inlined copy's resources are renamed at a fixed
|
||||
/// position: the inner artwork would lose its resource and silently vanish.
|
||||
/// A form whose content gives an operator a surplus operand (`0 /Inner Do`) is prepared as Ungroup
|
||||
/// draws it: the renderer draws the last operand, and that is the one renamed with the inner
|
||||
/// artwork's resource, so every drawn name still resolves.
|
||||
#[test]
|
||||
fn grouped_text_with_surplus_resource_operands_is_left_grouped() {
|
||||
fn grouped_text_with_a_surplus_resource_operand_renames_the_operand_drawn() {
|
||||
let form = |body: &str| {
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Inner 7 0 R >> >>", body)
|
||||
};
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Body 6 0 R >> >> /Contents 4 0 R >>"
|
||||
.to_string(),
|
||||
stream("", "/Body Do"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
form("BT /F1 14 Tf 40 500 Td (body) Tj ET 0 /Inner Do"),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << >>", "0 0 10 10 re f"),
|
||||
];
|
||||
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
|
||||
let result = text::forms::prepare(&mut doc, 0).unwrap();
|
||||
assert_eq!(result.expanded, 0);
|
||||
assert!(result.refused.iter().any(|r| r.starts_with("Body: ")), "{:?}", result.refused);
|
||||
assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before);
|
||||
// The same form with well-formed operands is prepared, its inner artwork renamed with it.
|
||||
let objs: Vec<String> =
|
||||
objs.iter().enumerate().map(|(i, o)| if i == 5 { form("BT /F1 14 Tf 40 500 Td (body) Tj ET /Inner Do") } else { o.clone() }).collect();
|
||||
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 1);
|
||||
let page = &pdfcraft_model::pages(&doc)[0];
|
||||
let xobjects = page.dict.get(b"Resources").and_then(|r| doc.dict(r)).and_then(|r| r.get(b"XObject").and_then(|x| doc.dict(x))).unwrap();
|
||||
for op in pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.iter().filter(|op| op.is("Do")) {
|
||||
assert!(xobjects.contains(op.last_name().unwrap()), "every drawn name resolves: {op:?}");
|
||||
for body in ["BT /F1 14 Tf 40 500 Td (body) Tj ET 0 /Inner Do", "BT /F1 14 Tf 40 500 Td (body) Tj ET /Inner Do"] {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Body 6 0 R >> >> /Contents 4 0 R >>"
|
||||
.to_string(),
|
||||
stream("", "/Body Do"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
form(body),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << >>", "0 0 10 10 re f"),
|
||||
];
|
||||
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 1, "{body}");
|
||||
let page = &pdfcraft_model::pages(&doc)[0];
|
||||
let xobjects = page.dict.get(b"Resources").and_then(|r| doc.dict(r)).and_then(|r| r.get(b"XObject").and_then(|x| doc.dict(x))).unwrap();
|
||||
for op in pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.iter().filter(|op| op.is("Do")) {
|
||||
assert!(xobjects.contains(op.last_name().unwrap()), "every drawn name resolves: {op:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12795,14 +12809,14 @@ fn grouped_image_artwork_is_not_prepared() {
|
||||
assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner"));
|
||||
}
|
||||
|
||||
/// Artwork and groups refused for what they are (knockout groups, content with an operand too
|
||||
/// many) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text
|
||||
/// Artwork and groups refused for what they are (knockout groups, content that restores more
|
||||
/// graphics states than it saves) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text
|
||||
/// group are 41 reads, within the 64-placement limit, where reading either again on the second
|
||||
/// pass would pass it and prepare nothing.
|
||||
#[test]
|
||||
fn artwork_and_refused_groups_count_once_against_the_placement_limit() {
|
||||
for knockout in [true, false] {
|
||||
let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET 0 /Im Do" };
|
||||
let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET Q" };
|
||||
let mut forms: Vec<(String, String)> = (1..=10).map(|i| (format!("Logo{i}"), "q 10 0 0 10 0 0 cm /Im Do Q".to_string())).collect();
|
||||
forms.extend((1..=30).map(|i| (format!("Refused{i}"), refused_body.to_string())));
|
||||
forms.push(("Body".into(), "BT /F1 14 Tf 40 300 Td (body) Tj ET".into()));
|
||||
@@ -12932,26 +12946,27 @@ fn grouped_text_under_a_non_utf8_resource_name_is_prepared() {
|
||||
assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "named"));
|
||||
}
|
||||
|
||||
/// A transparency group that blends in DeviceRGB composites as the page does only on a page
|
||||
/// whose own group says DeviceRGB too. On a page that names no blending space the processor
|
||||
/// chooses one (often CMYK in print), so inlining could change colours: that group is refused.
|
||||
/// Preparation follows Ungroup's rule for a transparency group that blends in DeviceRGB: it
|
||||
/// composites as the page does on a page whose own group says DeviceRGB, or that names no
|
||||
/// blending space (DeviceRGB on screen), and is prepared there; on a page that blends in CMYK it
|
||||
/// is refused.
|
||||
#[test]
|
||||
fn grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb() {
|
||||
for page_group in [false, true] {
|
||||
for page_space in [None, Some("DeviceRGB"), Some("DeviceCMYK")] {
|
||||
let mut doc = page_with_forms("/Body Do", &[("Body".into(), "0 0 0 1 k BT /F1 14 Tf 40 500 Td (body) Tj ET".into())]);
|
||||
let group = || build(&["<< /S /Transparency /CS /DeviceRGB >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone();
|
||||
let group = |cs: &str| build(&[&format!("<< /S /Transparency /CS /{cs} >>")]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone();
|
||||
let Object::Stream(mut s) = doc.get(pdfcraft_cos::ObjRef::new(7, 0)).as_ref().clone() else { panic!("fixture") };
|
||||
s.dict.set(b"Group".to_vec(), Object::Dict(group()));
|
||||
s.dict.set(b"Group".to_vec(), Object::Dict(group("DeviceRGB")));
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(s));
|
||||
if page_group {
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| d.set(b"Group".to_vec(), Object::Dict(group()))).unwrap();
|
||||
if let Some(cs) = page_space {
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| d.set(b"Group".to_vec(), Object::Dict(group(cs)))).unwrap();
|
||||
}
|
||||
let result = text::forms::prepare(&mut doc, 0).unwrap();
|
||||
if page_group {
|
||||
assert_eq!((result.expanded, result.refused.len()), (1, 0), "{:?}", result.refused);
|
||||
} else {
|
||||
if page_space == Some("DeviceCMYK") {
|
||||
assert_eq!(result.expanded, 0);
|
||||
assert!(result.refused.iter().any(|r| r.contains("colour space of its own")), "{:?}", result.refused);
|
||||
} else {
|
||||
assert_eq!((result.expanded, result.refused.len()), (1, 0), "{page_space:?}: {:?}", result.refused);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
//! source glyph operators become page content; no cover rectangle or replacement text box is
|
||||
//! added. Unsupported compositing and malformed streams stay intact.
|
||||
|
||||
mod expand;
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use super::{ContentStream, Read, content_streams, joined, page_dict, rewritten_contents, splice};
|
||||
use crate::EditError;
|
||||
use crate::{EditError, ungroup};
|
||||
|
||||
/// Result of preparing one page. Refused groups remain in the page, with an actionable reason.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -31,7 +29,7 @@ struct Inlined {
|
||||
/// Where each inlined form's operators are: the placements in them are new.
|
||||
ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Earlier placements refused only because their resources needed renaming
|
||||
/// ([`expand::Refusal::renaming`]), each with the reason it was given.
|
||||
/// ([`crate::ungroup::Refusal::renaming`]), each with the reason it was given.
|
||||
waiting: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
@@ -74,7 +72,7 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
};
|
||||
let mut still_waiting = Vec::new();
|
||||
let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let space = expand::page_blend_space(&next, &p.dict);
|
||||
let space = ungroup::page_blend_space(&next, &p.dict);
|
||||
let images: std::collections::BTreeMap<_, _> =
|
||||
crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect();
|
||||
let mut replacements = std::collections::BTreeMap::new();
|
||||
@@ -104,7 +102,7 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
if earlier {
|
||||
let Some(w) = waiting.iter().position(|(i, _)| *i == at) else { continue };
|
||||
let (_, reason) = waiting.swap_remove(w);
|
||||
if pass == MAX_LEVELS || !expand::merges_without_renaming(&next, &merged, &name) {
|
||||
if pass == MAX_LEVELS || !ungroup::merges_without_renaming(&next, &merged, &name) {
|
||||
still_waiting.push((at, reason));
|
||||
continue;
|
||||
}
|
||||
@@ -129,14 +127,22 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into()));
|
||||
}
|
||||
}
|
||||
let translucent = expand::translucent_at(&next, &merged, &ops, at);
|
||||
match expand::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) {
|
||||
Ok(expand::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => {
|
||||
let translucent = ungroup::translucent_at(&next, &merged, &ops, at);
|
||||
match ungroup::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) {
|
||||
Ok(ungroup::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => {
|
||||
// Vector and image artwork needs no preparation. Nested text groups are
|
||||
// visited on the next pass, with their inherited placement and clip in force.
|
||||
if !shows_text {
|
||||
settled.extend(image.object);
|
||||
continue;
|
||||
match shows_text {
|
||||
Some(true) => {}
|
||||
Some(false) => {
|
||||
settled.extend(image.object);
|
||||
continue;
|
||||
}
|
||||
// Whether it has text can't be known: it is left as it is, and said.
|
||||
None => {
|
||||
result.refused.push(format!("{}: it draws recursive forms, or nested content that can't be checked", image.name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if pass == MAX_LEVELS {
|
||||
result.refused.push(format!("{}: its text is grouped more than {MAX_LEVELS} levels deep", image.name));
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
//! Existing-text preparation: draw a Form XObject's content in place of its `Do` (#2964, #3005).
|
||||
//! Adapted from mtlcl410's Ungroup implementation in PR #3067, revision
|
||||
//! `6eafca9caa4a4d2174a395b53914bc9e689097cc` (MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own
|
||||
//! content streams. Ungrouping copies the form's content into the page at the same place,
|
||||
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
|
||||
//! the page's under names that don't clash. Its text then edits like any other. The form object
|
||||
//! itself is left alone, so another page or placement that draws it is unchanged.
|
||||
//!
|
||||
//! Drawing a form inline paints the same pixels unless the form is a transparency group whose
|
||||
//! compositing differs from painting its parts one by one: a knockout group, a group that uses
|
||||
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
|
||||
//! mask, or a group with a blending colour space (`/CS`) the page group doesn't name too: its
|
||||
//! contents composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they
|
||||
//! would composite in the page's, which a page without one leaves to the PDF processor. Those
|
||||
//! are refused rather than changed, as is tagged form content (its structure link would be lost)
|
||||
//! and content whose marked content or text objects don't balance (inlined, they would change
|
||||
//! the scope of the page's own). A form in a layer (`/OC`) keeps its membership as
|
||||
//! `/OC … BDC … EMC`.
|
||||
//!
|
||||
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
|
||||
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
|
||||
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
|
||||
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
|
||||
/// any real form, and a bomb must not be copied into the page.
|
||||
const MAX_FORM_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
|
||||
|
||||
/// What [`ungroup_ops`] gives back.
|
||||
pub(crate) struct Ungrouped {
|
||||
/// The ops that draw the form inline.
|
||||
pub(crate) ops: Vec<Op>,
|
||||
/// The page resources with the form's merged in.
|
||||
pub(crate) resources: Dict,
|
||||
/// The pattern copies to add to the document, each under the name in the merged `/Pattern`
|
||||
/// dictionary that the ops already use.
|
||||
pub(crate) patterns: Vec<(Vec<u8>, Object)>,
|
||||
/// Whether the form shows text, in its own content or in a Form XObject it draws (at any
|
||||
/// depth [`MAX_NESTING`] allows). Text inside a tiling pattern or Type 3 glyph doesn't count:
|
||||
/// inlining the form doesn't make it editable. Image-only and vector-only forms are artwork.
|
||||
pub(crate) shows_text: bool,
|
||||
}
|
||||
|
||||
/// Why [`ungroup_ops`] left a placement grouped.
|
||||
pub(crate) struct Refusal {
|
||||
pub(crate) error: EditError,
|
||||
/// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every
|
||||
/// placement of it is refused the same way. Otherwise something about this placement or the
|
||||
/// page so far is: its translucency or matrix, the resources it would merge into (which grow
|
||||
/// as other groups are prepared), or the decode budget left.
|
||||
pub(crate) intrinsic: bool,
|
||||
/// The form's resources need renaming where it is drawn, and it draws something that would
|
||||
/// look them up by their old names. Names merged in for other forms can make the renaming
|
||||
/// unnecessary, which [`merges_without_renaming`] checks without reading the content.
|
||||
pub(crate) renaming: bool,
|
||||
}
|
||||
|
||||
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
|
||||
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
|
||||
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
|
||||
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
|
||||
/// then the CTM there).
|
||||
pub(crate) fn ungroup_ops(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
name: &[u8],
|
||||
translucent: bool,
|
||||
page_space: Option<&Object>,
|
||||
placement: Matrix,
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Result<Ungrouped, Refusal> {
|
||||
let refusal = |why: &str, intrinsic: bool| Refusal {
|
||||
error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")),
|
||||
intrinsic,
|
||||
renaming: false,
|
||||
};
|
||||
// Something about this placement or the page so far is the reason.
|
||||
let refuse = |why: &str| refusal(why, false);
|
||||
// The form itself is the reason: every placement of it is refused the same way.
|
||||
let invalid = |why: &str| refusal(why, true);
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
|
||||
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
|
||||
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
|
||||
if !is_name(doc, &s.dict, b"Subtype", b"Form") {
|
||||
return Err(refuse("it is not a Form XObject"));
|
||||
}
|
||||
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
|
||||
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
|
||||
if a.as_array()?.len() != n {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
};
|
||||
let bbox = numbers(b"BBox", 4).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?;
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
None => None,
|
||||
Some(_) => Some(Matrix(
|
||||
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| invalid("its matrix is unreadable"))?)
|
||||
.map_err(|_| invalid("its matrix is unreadable"))?,
|
||||
)),
|
||||
};
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
if s.dict.get(b"Resources").is_some() && own.is_none() {
|
||||
return Err(invalid("its resources cannot be read"));
|
||||
}
|
||||
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
|
||||
let form_res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
|
||||
// A tagged form's content is a structure element's: drawn inline it would lose that link.
|
||||
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
|
||||
return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
}
|
||||
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
if s.dict.get(b"Group").is_some() && group.is_none() {
|
||||
return Err(invalid("its group dictionary cannot be read"));
|
||||
}
|
||||
if let Some(group) = &group {
|
||||
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
|
||||
return Err(invalid("it is a knockout transparency group"));
|
||||
}
|
||||
if translucent {
|
||||
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
|
||||
}
|
||||
// Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS);
|
||||
// inline they would composite in the page's.
|
||||
if let Some(cs) = group.get(b"CS")
|
||||
&& !same_blend_space(doc, cs, page_space)
|
||||
{
|
||||
return Err(invalid("it is a transparency group that blends in a colour space of its own"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly: damaged data must not be copied in as a shorter form.
|
||||
let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Err(invalid("its content has parts that can't be read"));
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
// A name is renamed at a fixed operand position below, but the renderer takes operands from
|
||||
// the end and ignores surplus ones before them: with a surplus operand the drawn name would
|
||||
// keep its old spelling while its resource moved, and draw nothing or the wrong thing. The
|
||||
// parser keeps such operands without counting them as skipped. Checked before the nested
|
||||
// walk, which can only call it unverifiable.
|
||||
if ops.iter().any(|op| takes_operands(&op.op).is_some_and(|n| op.operands.len() > n)) {
|
||||
return Err(invalid("its content gives an operator more operands than it takes"));
|
||||
}
|
||||
let shows_text = {
|
||||
// Check every form, including non-group forms, for recursive/unsupported content.
|
||||
// The form itself is being read: drawing itself again counts as blending.
|
||||
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false };
|
||||
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
|
||||
return Err(refuse("it has blend modes, recursive forms, or nested content that cannot be verified"));
|
||||
}
|
||||
walk.text
|
||||
};
|
||||
|
||||
// Merge the form's resources into a copy of the page's, renaming what clashes.
|
||||
let mut merged = resources.clone();
|
||||
let mut renames: Vec<(&[u8], Renames)> = Vec::new();
|
||||
if own.is_some() {
|
||||
for cat in CATEGORIES {
|
||||
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
if theirs.len() > 4096 {
|
||||
return Err(invalid("too many resources"));
|
||||
}
|
||||
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
if mine.len() > 4096 {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
let mut map = Renames::new();
|
||||
let mut n = 0u32;
|
||||
for (key, value) in theirs.iter() {
|
||||
if cat == b"ColorSpace" && matches!(key.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") && mine.get(key) != Some(value) {
|
||||
return Err(refuse("it overrides a default colour space"));
|
||||
}
|
||||
if mine.get(key) == Some(value) {
|
||||
continue;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUg{n}").into_bytes();
|
||||
if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() {
|
||||
break candidate;
|
||||
}
|
||||
if n == u32::MAX {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
};
|
||||
mine.set(fresh.clone(), value.clone());
|
||||
map.push((key.to_vec(), fresh));
|
||||
}
|
||||
merged.set(cat.to_vec(), Object::Dict(mine));
|
||||
if !map.is_empty() {
|
||||
renames.push((cat, map));
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
|
||||
renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone())
|
||||
};
|
||||
// A nested form, Type 3 font or tiling pattern without resources of its own looks its names
|
||||
// up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it
|
||||
// would find the page's, which differ wherever a name was renamed (every form resource that
|
||||
// isn't already the page's, the nested item's own entry included).
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
|
||||
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
// Marked content and text objects must balance within the form's own stream: an extra EMC
|
||||
// would close one the page opened, and one left open would take in the page's later content.
|
||||
let mut marked = 0usize;
|
||||
let mut in_text = false;
|
||||
for op in &mut ops {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(invalid("its content begins a text object inside another")),
|
||||
b"BT" => in_text = true,
|
||||
b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")),
|
||||
b"ET" => in_text = false,
|
||||
_ => {}
|
||||
}
|
||||
let slot: Option<(&[u8], usize)> = match op.op.as_slice() {
|
||||
b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => {
|
||||
let cat: &[u8] = match op.op.as_slice() {
|
||||
b"Tf" => b"Font",
|
||||
b"Do" => b"XObject",
|
||||
b"gs" => b"ExtGState",
|
||||
b"sh" => b"Shading",
|
||||
_ => b"ColorSpace",
|
||||
};
|
||||
Some((cat, 0))
|
||||
}
|
||||
b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)),
|
||||
b"BDC" | b"DP" => Some((&b"Properties"[..], 1)),
|
||||
b"q" => {
|
||||
if depth >= 4096 {
|
||||
return Err(invalid("graphics state is too deeply nested"));
|
||||
}
|
||||
depth += 1;
|
||||
None
|
||||
}
|
||||
b"Q" => {
|
||||
depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((cat, i)) = slot
|
||||
&& let Some(operand) = op.operands.get_mut(i)
|
||||
&& let Object::Name(n) = operand
|
||||
&& let Some(to) = renamed(cat, n)
|
||||
{
|
||||
*operand = Object::Name(to);
|
||||
}
|
||||
if let Some((dict, _)) = &mut op.inline {
|
||||
for key in [&b"CS"[..], b"ColorSpace"] {
|
||||
if let Some(Object::Name(n)) = dict.get(key)
|
||||
&& let Some(to) = renamed(b"ColorSpace", n)
|
||||
{
|
||||
dict.set(key.to_vec(), Object::Name(to));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if marked > 0 || in_text {
|
||||
return Err(invalid("its content leaves marked content or a text object open"));
|
||||
}
|
||||
|
||||
// Patterns keep their place relative to the shapes they fill: each one the form selects is
|
||||
// drawn from a copy that also carries the placement. Nested forms and patterns inside
|
||||
// patterns keep their own parents, so only this stream's selections change.
|
||||
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
|
||||
let mut pattern_bytes = MAX_FORM_BYTES;
|
||||
if placement != Matrix::IDENTITY {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut moved: Renames = Vec::new();
|
||||
let mut n = 0u32;
|
||||
for op in &mut ops {
|
||||
if !(op.is("scn") || op.is("SCN")) {
|
||||
continue;
|
||||
}
|
||||
let Some(i) = op.operands.len().checked_sub(1) else { continue };
|
||||
let Some(Object::Name(from)) = op.operands.get(i) else { continue };
|
||||
// A name the resources don't have selects nothing, inline or not.
|
||||
let Some(entry) = table.get(from).cloned() else { continue };
|
||||
let to = match moved.iter().find(|(f, _)| f == from) {
|
||||
Some((_, to)) => to.clone(),
|
||||
None => {
|
||||
if moved.len() >= MAX_MOVED_PATTERNS {
|
||||
return Err(refuse("it paints with too many patterns"));
|
||||
}
|
||||
let copy =
|
||||
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
|
||||
if let Object::Stream(s) = © {
|
||||
pattern_bytes =
|
||||
pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies exceed the editing budget"))?;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUgPat{n}").into_bytes();
|
||||
if table.get(&candidate).is_none() {
|
||||
break candidate;
|
||||
}
|
||||
if n == u32::MAX {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
};
|
||||
// Reserved here; the caller replaces it with the copy's reference.
|
||||
table.set(fresh.clone(), Object::Null);
|
||||
patterns.push((fresh.clone(), copy));
|
||||
moved.push((from.clone(), fresh.clone()));
|
||||
fresh
|
||||
}
|
||||
};
|
||||
if let Some(operand) = op.operands.get_mut(i) {
|
||||
*operand = Object::Name(to);
|
||||
}
|
||||
}
|
||||
if !patterns.is_empty() {
|
||||
merged.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
}
|
||||
|
||||
// A form in a layer (/OC) stays in it: its content is marked with the same membership.
|
||||
let layer = match s.dict.get(b"OC") {
|
||||
None => None,
|
||||
Some(oc) => {
|
||||
let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone());
|
||||
let name = match existing {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
let fresh = (1..=u32::MAX)
|
||||
.map(|n| format!("PCUgOC{n}").into_bytes())
|
||||
.find(|c| props.get(c).is_none())
|
||||
.ok_or_else(|| refuse("too many resources"))?;
|
||||
props.set(fresh.clone(), oc.clone());
|
||||
merged.set(b"Properties".to_vec(), Object::Dict(props));
|
||||
fresh
|
||||
}
|
||||
};
|
||||
Some(name)
|
||||
}
|
||||
};
|
||||
|
||||
let num = |v: f64| Object::Real(v);
|
||||
let mut out = Vec::new();
|
||||
if let Some(name) = &layer {
|
||||
out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())]));
|
||||
}
|
||||
out.push(Op::new("q", Vec::new()));
|
||||
if let Some(m) = matrix {
|
||||
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
|
||||
}
|
||||
let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| invalid("invalid form bounds"))?;
|
||||
let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d));
|
||||
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
|
||||
out.push(Op::new("W", Vec::new()));
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
out.extend(ops);
|
||||
// A form discards its unfinished current path when returning to its caller.
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
// A form ends with the state it was drawn with (the Do saves and restores it).
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
out.push(Op::new("Q", Vec::new()));
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text })
|
||||
}
|
||||
|
||||
/// Whether form `name` in `resources` would merge into them without a rename: it has resources
|
||||
/// of its own, and each of their entries is already in `resources` under the same name, as
|
||||
/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content.
|
||||
pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool {
|
||||
let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false };
|
||||
let Object::Stream(s) = &*doc.resolve(&form) else { return false };
|
||||
let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false };
|
||||
CATEGORIES.into_iter().all(|cat| {
|
||||
let Some(theirs) = category(&own, cat) else { return true };
|
||||
let mine = category(resources, cat).unwrap_or_default();
|
||||
// Past these sizes `ungroup_ops` refuses the form anyway.
|
||||
theirs.len() <= 4096 && mine.len() <= 4096 && theirs.iter().all(|(key, value)| mine.get(key) == Some(value))
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
|
||||
/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its
|
||||
/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy.
|
||||
fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Object> {
|
||||
let resolved = doc.resolve(entry);
|
||||
let (mut dict, stream) = match &*resolved {
|
||||
Object::Stream(s) => (s.dict.clone(), Some(s)),
|
||||
Object::Dict(d) => (d.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
let own = match dict.get(b"Matrix") {
|
||||
None => Matrix::IDENTITY,
|
||||
Some(m) => {
|
||||
let m = doc.resolve(m);
|
||||
if m.as_array()?.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
|
||||
Matrix::from_operands(&nums)?
|
||||
}
|
||||
};
|
||||
let moved = own.then(&placement);
|
||||
if !moved.0.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect()));
|
||||
match stream {
|
||||
Some(s) if s.raw.len() > MAX_FORM_BYTES => None,
|
||||
Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })),
|
||||
None => Some(Object::Dict(dict)),
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
|
||||
const MAX_NESTING: usize = 8;
|
||||
|
||||
/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest
|
||||
/// count as blending, so a file can't make the check run for ever.
|
||||
const MAX_NESTED_READS: usize = 64;
|
||||
|
||||
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
|
||||
/// form drawing itself), the answers for each form with resources of its own (whether it blends
|
||||
/// and whether it shows text; neither depends on who draws it), how many reads it has made, and
|
||||
/// whether the content being walked shows text itself or through a Form XObject it draws.
|
||||
struct BlendWalk<'a> {
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, (bool, bool)>,
|
||||
reads: usize,
|
||||
budget: &'a mut DecodeBudget,
|
||||
text: bool,
|
||||
}
|
||||
|
||||
/// Whether `op` shows text (ISO 32000-2 §9.4.3, Table 107).
|
||||
fn shows_text(op: &Op) -> bool {
|
||||
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")
|
||||
}
|
||||
|
||||
/// How many operands an operator that names a resource takes (ISO 32000-2 Annex A), its name
|
||||
/// being the first of them: only for those whose name is read at a fixed position here.
|
||||
fn takes_operands(op: &[u8]) -> Option<usize> {
|
||||
match op {
|
||||
b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => Some(1),
|
||||
b"Tf" | b"BDC" | b"DP" => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from
|
||||
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with.
|
||||
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
|
||||
let (cat, i): (&[u8], usize) = match op.op.as_slice() {
|
||||
b"Do" => (b"XObject", 0),
|
||||
b"Tf" => (b"Font", 0),
|
||||
b"scn" | b"SCN" => match op.operands.len().checked_sub(1) {
|
||||
Some(i) => (b"Pattern", i),
|
||||
None => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
let Some(Object::Name(name)) = op.operands.get(i) else { return false };
|
||||
let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else {
|
||||
return false;
|
||||
};
|
||||
let dict = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => s.dict.clone(),
|
||||
Object::Dict(d) => d.clone(),
|
||||
_ => return false,
|
||||
};
|
||||
let drawn = match cat {
|
||||
b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"),
|
||||
b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"),
|
||||
// A shading pattern has no content of its own.
|
||||
_ => int_of(doc, &dict, b"PatternType") == Some(1),
|
||||
};
|
||||
drawn && dict.get(b"Resources").is_none()
|
||||
}
|
||||
|
||||
/// Whether `ops` blend through a used graphics state, nested form or pattern. Unreadable,
|
||||
/// recursive or over-budget nested content is treated as blending, so preparation refuses it.
|
||||
/// Sets `walk.text` when `ops` show text, or a Form XObject they draw does; text in a tiling
|
||||
/// pattern's cell is not the page's text and is not counted.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool {
|
||||
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
|
||||
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
if ops.iter().any(shows_text) {
|
||||
walk.text = true;
|
||||
}
|
||||
for op in ops {
|
||||
// Read at operand 0 below; with a surplus operand the renderer draws another name, which
|
||||
// this walk can't vouch for.
|
||||
if takes_operands(&op.op).is_some_and(|n| op.operands.len() > n) {
|
||||
return true;
|
||||
}
|
||||
if op.is("gs") && state_blends(op.name(0).and_then(|n| states.get(n))) {
|
||||
return true;
|
||||
}
|
||||
let o = if op.is("Do") {
|
||||
op.name(0).and_then(|n| xobjects.get(n))
|
||||
} else if op.is("scn") || op.is("SCN") {
|
||||
op.operands.len().checked_sub(1).and_then(|i| op.name(i)).and_then(|n| patterns.get(n))
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(o) = o else { continue };
|
||||
let resolved = doc.resolve(o);
|
||||
let nested = match &*resolved {
|
||||
Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st,
|
||||
Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st,
|
||||
// A shading pattern.
|
||||
Object::Stream(st) if !op.is("Do") => {
|
||||
if state_blends(st.dict.get(b"ExtGState")) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Object::Dict(d) if !op.is("Do") => {
|
||||
if state_blends(d.get(b"ExtGState")) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let r = o.as_ref();
|
||||
let drawn_form = op.is("Do");
|
||||
if let Some((known, text)) = r.and_then(|r| walk.known.get(&r)).copied() {
|
||||
if known {
|
||||
return true;
|
||||
}
|
||||
walk.text |= drawn_form && text;
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
|
||||
return true;
|
||||
}
|
||||
walk.reads += 1;
|
||||
let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true };
|
||||
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let has_own = own_resources.is_some();
|
||||
let own = own_resources.unwrap_or_else(|| resources.clone());
|
||||
let inner = parse(&data);
|
||||
if inner.skipped > 0 {
|
||||
return true;
|
||||
}
|
||||
if let Some(r) = r {
|
||||
walk.path.push(r);
|
||||
}
|
||||
let outer_text = std::mem::replace(&mut walk.text, false);
|
||||
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
|
||||
let inner_text = walk.text;
|
||||
walk.text = outer_text || (drawn_form && inner_text);
|
||||
if let Some(r) = r {
|
||||
walk.path.retain(|x| *x != r);
|
||||
if has_own {
|
||||
walk.known.insert(r, (blends, inner_text));
|
||||
}
|
||||
}
|
||||
if blends {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency
|
||||
/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses.
|
||||
pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option<Object> {
|
||||
let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?;
|
||||
group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone())
|
||||
}
|
||||
|
||||
/// Whether a group blending in `cs` composites as the page does: only when the page group names
|
||||
/// the same space. A page that names none composites in a space the PDF processor chooses
|
||||
/// (ISO 32000-2 §11.4.7): DeviceRGB on screen, but often DeviceCMYK when printed, where a group
|
||||
/// that blends in DeviceRGB converts its CMYK and gray colours through RGB and inline they would
|
||||
/// not be. Such a group is refused rather than changed.
|
||||
fn same_blend_space(doc: &Document, cs: &Object, page_space: Option<&Object>) -> bool {
|
||||
page_space.is_some_and(|page| *doc.resolve(cs) == *page)
|
||||
}
|
||||
|
||||
/// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may
|
||||
/// be one, ISO 32000-2 §7.3.10).
|
||||
fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool {
|
||||
d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want))
|
||||
}
|
||||
|
||||
/// `d`'s integer `key`, directly or through an indirect object.
|
||||
fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<i64> {
|
||||
d.get(key).and_then(|v| doc.resolve(v).as_int())
|
||||
}
|
||||
|
||||
/// Whether an ExtGState blends with something other than Normal.
|
||||
fn blends(doc: &Document, gs: &Dict) -> bool {
|
||||
let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false };
|
||||
let first = match &*bm {
|
||||
Object::Array(a) => a.first().map(|x| doc.resolve(x)),
|
||||
_ => Some(bm.clone()),
|
||||
};
|
||||
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
|
||||
}
|
||||
|
||||
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's
|
||||
/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs`
|
||||
/// changes only the parameters it sets.
|
||||
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool {
|
||||
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
|
||||
let mut stack: Vec<[bool; 4]> = Vec::new();
|
||||
let mut now = [false; 4];
|
||||
for op in ops.iter().take(at) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(now),
|
||||
b"Q" => now = stack.pop().unwrap_or([false; 4]),
|
||||
b"gs" => {
|
||||
let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0);
|
||||
if let Some(v) = alpha(b"ca") {
|
||||
now[0] = v;
|
||||
}
|
||||
if let Some(v) = alpha(b"CA") {
|
||||
now[1] = v;
|
||||
}
|
||||
if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) {
|
||||
now[2] = m.as_name() != Some(b"None");
|
||||
}
|
||||
if gs.get(b"BM").is_some() {
|
||||
now[3] = blends(doc, &gs);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
now.iter().any(|v| *v)
|
||||
}
|
||||
+168
-54
@@ -23,17 +23,21 @@
|
||||
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
|
||||
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
|
||||
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
|
||||
//!
|
||||
//! Edit text's preparation of grouped text (`text::forms`) opens each placement with this same
|
||||
//! code, and decides what to open from what [`Refusal`] and [`Ungrouped::shows_text`] say.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
|
||||
/// any real form, and a bomb must not be copied into the page.
|
||||
const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
/// any real form, and a bomb must not be copied into the page. Ungroup reads the form and what it
|
||||
/// draws within one [`DecodeBudget`] of this size.
|
||||
pub(crate) const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
@@ -45,19 +49,50 @@ const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
|
||||
/// The most entries of one resource category merged: a form or page with more is refused.
|
||||
const MAX_RESOURCES: usize = 4096;
|
||||
|
||||
/// The deepest `q` nesting a form's content may have.
|
||||
const MAX_SAVES: usize = 4096;
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
|
||||
|
||||
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
|
||||
/// the form's merged in, and the pattern copies to add to the document, each under the name in
|
||||
/// the merged `/Pattern` dictionary that the ops already use.
|
||||
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
|
||||
/// What [`ungroup_ops`] gives back.
|
||||
pub(crate) struct Ungrouped {
|
||||
/// The ops that draw the form inline.
|
||||
pub(crate) ops: Vec<Op>,
|
||||
/// The page resources with the form's merged in.
|
||||
pub(crate) resources: Dict,
|
||||
/// The pattern copies to add to the document, each under the name in the merged `/Pattern`
|
||||
/// dictionary that the ops already use.
|
||||
pub(crate) patterns: Vec<(Vec<u8>, Object)>,
|
||||
/// Whether the form shows text, in its own content or in a Form XObject it draws (at any
|
||||
/// depth [`MAX_NESTING`] allows). Text inside a tiling pattern or Type 3 glyph doesn't count:
|
||||
/// inlining the form doesn't make it editable. `None` when something it draws can't be read,
|
||||
/// is nested too deep, draws itself, or is past the reads or the decode budget allowed.
|
||||
pub(crate) shows_text: Option<bool>,
|
||||
}
|
||||
|
||||
/// Why [`ungroup_ops`] left a placement grouped.
|
||||
pub(crate) struct Refusal {
|
||||
pub(crate) error: EditError,
|
||||
/// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every
|
||||
/// placement of it is refused the same way. Otherwise something about this placement or the
|
||||
/// page so far is: its translucency or matrix, the resources it would merge into (which grow
|
||||
/// as other groups are prepared), or the decode budget left.
|
||||
pub(crate) intrinsic: bool,
|
||||
/// The form's resources need renaming where it is drawn, and it draws something that would
|
||||
/// look them up by their old names. Names merged in for other forms can make the renaming
|
||||
/// unnecessary, which [`merges_without_renaming`] checks without reading the content.
|
||||
pub(crate) renaming: bool,
|
||||
}
|
||||
|
||||
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
|
||||
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
|
||||
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
|
||||
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
|
||||
/// then the CTM there).
|
||||
/// then the CTM there). The form, and the forms and patterns it draws, decode within `budget`.
|
||||
pub(crate) fn ungroup_ops(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
@@ -65,8 +100,17 @@ pub(crate) fn ungroup_ops(
|
||||
translucent: bool,
|
||||
page_space: Option<&Object>,
|
||||
placement: Matrix,
|
||||
) -> Result<Ungrouped, EditError> {
|
||||
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Result<Ungrouped, Refusal> {
|
||||
let refusal = |why: &str, intrinsic: bool| Refusal {
|
||||
error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")),
|
||||
intrinsic,
|
||||
renaming: false,
|
||||
};
|
||||
// Something about this placement or the page so far is the reason.
|
||||
let refuse = |why: &str| refusal(why, false);
|
||||
// The form itself is the reason: every placement of it is refused the same way.
|
||||
let invalid = |why: &str| refusal(why, true);
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
|
||||
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
|
||||
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
|
||||
@@ -75,29 +119,35 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
|
||||
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
let a = a.as_array()?;
|
||||
// Every entry must be a number: a short array padded out by a non-number is unreadable.
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(a.len() == n && v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
};
|
||||
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
|
||||
let bbox = numbers(b"BBox", 4).and_then(|b| <[f64; 4]>::try_from(b).ok()).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?;
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
None => None,
|
||||
Some(_) => Some(Matrix(
|
||||
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
|
||||
.map_err(|_| refuse("its matrix is unreadable"))?,
|
||||
)),
|
||||
Some(_) => Some(Matrix(numbers(b"Matrix", 6).and_then(|m| <[f64; 6]>::try_from(m).ok()).ok_or_else(|| invalid("its matrix is unreadable"))?)),
|
||||
};
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
if s.dict.get(b"Resources").is_some() && own.is_none() {
|
||||
return Err(invalid("its resources can't be read"));
|
||||
}
|
||||
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
|
||||
let form_res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
|
||||
// A tagged form's content is a structure element's: drawn inline it would lose that link.
|
||||
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
|
||||
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
}
|
||||
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
let group_dict = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned());
|
||||
if s.dict.get(b"Group").is_some() && group_dict.is_none() {
|
||||
return Err(invalid("its group dictionary can't be read"));
|
||||
}
|
||||
let group = group_dict.filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
if let Some(group) = &group {
|
||||
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
|
||||
return Err(refuse("it is a knockout transparency group"));
|
||||
return Err(invalid("it is a knockout transparency group"));
|
||||
}
|
||||
if translucent {
|
||||
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
|
||||
@@ -107,24 +157,27 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(cs) = group.get(b"CS")
|
||||
&& !same_blend_space(doc, cs, page_space)
|
||||
{
|
||||
return Err(refuse("it is a transparency group that blends in a colour space of its own"));
|
||||
return Err(invalid("it is a transparency group that blends in a colour space of its own"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly: damaged data must not be copied in as a shorter form.
|
||||
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Err(refuse("its content has parts that can't be read"));
|
||||
return Err(invalid("its content has parts that can't be read"));
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
if group.is_some() {
|
||||
// The form itself is being read: drawing itself again counts as blending.
|
||||
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0 };
|
||||
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
|
||||
return Err(refuse("it is a transparency group with blend modes inside"));
|
||||
}
|
||||
// What it draws is read for text whether it is a group or not, and for blend modes only in a
|
||||
// group: without one, blending inside draws the same inline. The form itself is being read,
|
||||
// so drawing itself again is content that can't be checked.
|
||||
let mut walk =
|
||||
BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false, group: group.is_some() };
|
||||
let unchecked = blends_inside(doc, &form_res, &ops, 0, &mut walk);
|
||||
if unchecked && group.is_some() {
|
||||
return Err(refuse("it is a transparency group with blend modes inside, or content inside that can't be checked"));
|
||||
}
|
||||
let shows_text = (!unchecked).then_some(walk.text);
|
||||
|
||||
// A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline,
|
||||
// the page's resources are the current ones (§7.8.3).
|
||||
@@ -141,7 +194,13 @@ pub(crate) fn ungroup_ops(
|
||||
if own.is_some() {
|
||||
for cat in CATEGORIES {
|
||||
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
if theirs.len() > MAX_RESOURCES {
|
||||
return Err(invalid("too many resources"));
|
||||
}
|
||||
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
if mine.len() > MAX_RESOURCES {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
let mut map = Renames::new();
|
||||
let mut n = 0u32;
|
||||
for (key, value) in theirs.iter() {
|
||||
@@ -177,7 +236,7 @@ pub(crate) fn ungroup_ops(
|
||||
// would find the page's, which differ wherever a name was renamed (every form resource that
|
||||
// isn't already the page's, the nested item's own entry included).
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
|
||||
return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources"));
|
||||
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
// Marked content and text objects must balance within the form's own stream: an extra EMC
|
||||
@@ -187,19 +246,20 @@ pub(crate) fn ungroup_ops(
|
||||
for op in &mut ops {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(refuse("its content begins a text object inside another")),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(invalid("its content begins a text object inside another")),
|
||||
b"BT" => in_text = true,
|
||||
b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")),
|
||||
b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")),
|
||||
b"ET" => in_text = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" => depth = depth.saturating_add(1),
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?,
|
||||
b"q" if depth >= MAX_SAVES => return Err(invalid("its graphics states are nested too deeply")),
|
||||
b"q" => depth += 1,
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?,
|
||||
_ => {}
|
||||
}
|
||||
let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?;
|
||||
let slot = resource_operand(op).map_err(|Unreadable| invalid("its content selects a resource with operands that can't be read"))?;
|
||||
if let Some((cat, i)) = slot
|
||||
&& let Some(operand) = op.operands.get_mut(i)
|
||||
&& let Object::Name(n) = operand
|
||||
@@ -219,13 +279,15 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
|
||||
if marked > 0 || in_text {
|
||||
return Err(refuse("its content leaves marked content or a text object open"));
|
||||
return Err(invalid("its content leaves marked content or a text object open"));
|
||||
}
|
||||
|
||||
// Patterns keep their place relative to the shapes they fill: each one the form selects is
|
||||
// drawn from a copy that also carries the placement. Nested forms and patterns inside
|
||||
// patterns keep their own parents, so only this stream's selections change.
|
||||
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
|
||||
// What the copies of tiling patterns may add in all.
|
||||
let mut pattern_bytes = MAX_FORM_BYTES;
|
||||
if placement != Matrix::IDENTITY {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut moved: Renames = Vec::new();
|
||||
@@ -243,6 +305,9 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let copy =
|
||||
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
|
||||
if let Object::Stream(s) = © {
|
||||
pattern_bytes = pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies would be too large"))?;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUgPat{n}").into_bytes();
|
||||
@@ -300,18 +365,40 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(m) = matrix {
|
||||
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
|
||||
}
|
||||
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
|
||||
let [a, b, c, d] = bbox;
|
||||
let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d));
|
||||
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
|
||||
out.push(Op::new("W", Vec::new()));
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
out.extend(ops);
|
||||
// A path the form leaves unfinished ends with it, not in the page's content after it.
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
// A form ends with the state it was drawn with (the Do saves and restores it).
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
out.push(Op::new("Q", Vec::new()));
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok((out, merged, patterns))
|
||||
Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text })
|
||||
}
|
||||
|
||||
/// Whether form `name` in `resources` would merge into them without a rename: it has resources
|
||||
/// of its own, and each of their entries is already in `resources` under the same name, as
|
||||
/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content.
|
||||
pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool {
|
||||
let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false };
|
||||
let Object::Stream(s) = &*doc.resolve(&form) else { return false };
|
||||
let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false };
|
||||
CATEGORIES.into_iter().all(|cat| {
|
||||
let Some(theirs) = category(&own, cat) else { return true };
|
||||
let mine = category(resources, cat).unwrap_or_default();
|
||||
// Past these sizes `ungroup_ops` refuses the form anyway. Default colour spaces are
|
||||
// never renamed (`ungroup_ops` checks them separately).
|
||||
theirs.len() <= MAX_RESOURCES
|
||||
&& mine.len() <= MAX_RESOURCES
|
||||
&& theirs.iter().all(|(key, value)| mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..])))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace`
|
||||
@@ -339,6 +426,10 @@ fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Ob
|
||||
Some(m) => {
|
||||
let m = doc.resolve(m);
|
||||
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
|
||||
// A matrix is six numbers: a longer array is unreadable, not cut short.
|
||||
if nums.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
Matrix::from_operands(&nums)?
|
||||
}
|
||||
};
|
||||
@@ -362,12 +453,23 @@ const MAX_NESTING: usize = 8;
|
||||
const MAX_NESTED_READS: usize = 1024;
|
||||
|
||||
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
|
||||
/// form drawing itself), the answer for each form with resources of its own (its answer doesn't
|
||||
/// depend on who draws it), and how many reads it has made.
|
||||
struct BlendWalk {
|
||||
/// form drawing itself), the answers for each form with resources of its own (whether it blends
|
||||
/// or can't be checked, and whether it shows text; neither depends on who draws it), how many
|
||||
/// reads it has made, the decode budget they share with the form itself, whether the content
|
||||
/// being walked shows text itself or through a Form XObject it draws, and whether blend modes
|
||||
/// count (they do in a group).
|
||||
struct BlendWalk<'a> {
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, bool>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, (bool, bool)>,
|
||||
reads: usize,
|
||||
budget: &'a mut DecodeBudget,
|
||||
text: bool,
|
||||
group: bool,
|
||||
}
|
||||
|
||||
/// Whether `op` shows text (ISO 32000-2 §9.4.3, Table 107).
|
||||
fn shows_text(op: &Op) -> bool {
|
||||
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")
|
||||
}
|
||||
|
||||
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
|
||||
@@ -440,15 +542,22 @@ fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
|
||||
drawn && dict.get(b"Resources").is_none()
|
||||
}
|
||||
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
|
||||
/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through
|
||||
/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't
|
||||
/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather
|
||||
/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool {
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts
|
||||
/// only in a group (`walk.group`): a `gs` they use, or one used by a form or pattern they draw, at
|
||||
/// any depth. A shading pattern blends through its own `/ExtGState`, a tiling pattern through its
|
||||
/// content. A nested form or pattern that can't be read, is nested too deep, draws itself or is
|
||||
/// past the reads or the budget allowed counts as blending, so the caller refuses rather than
|
||||
/// guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped. Sets
|
||||
/// `walk.text` when `ops` show text, or a Form XObject they draw does; text in a tiling pattern's
|
||||
/// cell is not the page's text and is not counted.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool {
|
||||
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
|
||||
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
let group = walk.group;
|
||||
let state_blends = |g: Option<&Object>| group && g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
if ops.iter().any(shows_text) {
|
||||
walk.text = true;
|
||||
}
|
||||
for op in ops {
|
||||
let Ok(Some((cat, i))) = resource_operand(op) else { continue };
|
||||
let Some(name) = op.name(i) else { continue };
|
||||
@@ -479,17 +588,19 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
_ => continue,
|
||||
};
|
||||
let r = o.as_ref();
|
||||
if let Some(known) = r.and_then(|r| walk.known.get(&r)) {
|
||||
if *known {
|
||||
let drawn_form = op.is("Do");
|
||||
if let Some((known, text)) = r.and_then(|r| walk.known.get(&r)).copied() {
|
||||
if known {
|
||||
return true;
|
||||
}
|
||||
walk.text |= drawn_form && text;
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
|
||||
return true;
|
||||
}
|
||||
walk.reads += 1;
|
||||
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
|
||||
let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true };
|
||||
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let has_own = own_resources.is_some();
|
||||
let own = own_resources.unwrap_or_else(|| resources.clone());
|
||||
@@ -500,11 +611,14 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
if let Some(r) = r {
|
||||
walk.path.push(r);
|
||||
}
|
||||
let outer_text = std::mem::replace(&mut walk.text, false);
|
||||
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
|
||||
let inner_text = walk.text;
|
||||
walk.text = outer_text || (drawn_form && inner_text);
|
||||
if let Some(r) = r {
|
||||
walk.path.retain(|x| *x != r);
|
||||
if has_own {
|
||||
walk.known.insert(r, blends);
|
||||
walk.known.insert(r, (blends, inner_text));
|
||||
}
|
||||
}
|
||||
if blends {
|
||||
|
||||
Reference in New Issue
Block a user