Merge #4114 (fix/coderabbit-b-shared) into fix/coderabbit-c4-core

#4114 moves the bodies of Stream::decoded_within and decoded_strict into
decoded_within_typed and decoded_strict_within_typed, which return
FilterError so DecodeBudget can tell a spent limit from corrupt data.
This branch checks /Filter with readable_filters there. Kept #4114's
typed functions and made readable_filters return
FilterError::Unsupported, so a /Filter that is not a name fails both
typed decodes without charging the budget; decoded_first_filter_strict
maps it to CosError as before.
This commit is contained in:
aiongg
2026-10-22 20:30:25 +00:00
32 changed files with 945 additions and 227 deletions
+25 -16
View File
@@ -4,7 +4,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream};
/// A Figure element (after the role map).
#[derive(Clone, Debug, PartialEq)]
@@ -23,6 +23,8 @@ pub enum AltError {
Untagged,
#[error("object {0} is not a figure")]
NotAFigure(u32),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -157,22 +159,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option<i
}
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
page_content_within(doc, page, &mut DecodeBudget::page())
}
/// The page's content streams, decoded against `budget` and joined with a space. Streams that
/// don't decode, or arrive after the budget is spent, are left out.
pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec<u8> {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
match &*doc.resolve(c) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b' ');
v
}
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
let mut out = Vec::new();
for data in budget.decode_contents(doc, c).into_iter().flatten() {
out.extend_from_slice(&data);
out.push(b' ');
}
out
}
fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
@@ -375,9 +374,19 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
let props = page_properties(doc, &page.dict);
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
let mut budget = DecodeBudget::page();
for entry in &entries {
match &*doc.resolve(entry) {
Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
// Content past the budget may hold more of the figure: editing the rest
// would leave that part marked as a figure the tree no longer has.
Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
Err(_) => Vec::new(),
};
streams.push(Some((s.clone(), data)));
}
_ => streams.push(None),
}
}
+26 -24
View File
@@ -6,7 +6,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
use pdfcraft_model::Page;
use crate::{Finding, Rule};
@@ -20,6 +20,8 @@ pub(crate) struct Scan {
pub untagged: BTreeMap<usize, usize>,
/// Page → fonts used.
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
/// Pages read only in part: content or forms past the page's decode budget are missing.
pub incomplete: BTreeSet<usize>,
}
const MAX_FORM_DEPTH: usize = 12;
@@ -30,38 +32,25 @@ impl Scan {
for &p in list {
let Some(page) = pages.get(p) else { continue };
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let data = page_content(doc, &page.dict);
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() };
let mut budget = DecodeBudget::page();
let data = crate::alt::page_content_within(doc, &page.dict, &mut budget);
if budget.remaining() == 0 {
s.incomplete.insert(p);
}
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget };
w.content(&data, &res, false, 0);
}
s
}
}
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
match &*doc.resolve(c) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b' ');
v
}
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
struct Walk<'a> {
doc: &'a Document,
scan: &'a mut Scan,
page: usize,
forms: HashSet<ObjRef>,
/// Decoded bytes and form entries left for this page.
budget: DecodeBudget,
}
impl Walk<'_> {
@@ -116,14 +105,27 @@ impl Walk<'_> {
self.scan.has_images = true;
untagged += usize::from(!inside);
}
Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => {
Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => {
if !self.budget.enter_form() {
self.scan.incomplete.insert(self.page);
continue;
}
let data = match self.budget.decode(x) {
Ok(data) => data,
Err(_) => {
if self.budget.exhausted() {
self.scan.incomplete.insert(self.page);
}
continue;
}
};
self.forms.insert(r);
let inner = x
.dict
.get(b"Resources")
.map(|o| self.doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.unwrap_or_else(|| res.clone());
let data = x.decoded().unwrap_or_default();
self.content(&data, &inner, inside, depth + 1);
self.forms.remove(&r);
}
+12
View File
@@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report {
}
}
}
// A page read only in part may hold what the scan didn't find: text that makes the
// document more than images, untagged content, or a font without Unicode mappings.
let unsure = !scan.incomplete.is_empty()
&& match rule {
Rule::ImageOnly => !scan.has_text,
Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(),
_ => false,
};
if unsure {
results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
continue;
}
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);
+55
View File
@@ -1252,3 +1252,58 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
assert_eq!(titles, ["I Overview"]);
}
#[test]
fn repeated_content_references_share_one_decode_budget() {
use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream};
let mut doc = Document::new_empty();
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
let data = crate::alt::page_content(&doc, &page);
assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len());
}
/// A figure whose marked content goes on in a stream past the page's decode budget is refused,
/// not removed with that content still pointing at it.
#[test]
fn marking_decorative_refuses_content_past_the_decode_budget() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec()));
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
contents.push(Object::Ref(last));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err());
assert_eq!(figures(&doc).len(), 2, "the figure stays");
}
/// Text past the page's decode budget is unread, so a page that shows images and no text so far
/// can't be called image-only, nor its content all tagged.
#[test]
fn content_past_the_decode_budget_needs_a_manual_check() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>"
.into(),
stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
.into(),
FONT.into(),
],
"",
);
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec()));
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
contents.push(Object::Ref(text));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
let report = check(&doc, &Options::default());
assert_eq!(status(&report, Rule::ImageOnly), Status::Manual);
assert_eq!(status(&report, Rule::TaggedContent), Status::Manual);
}
+13 -5
View File
@@ -16,7 +16,7 @@
//! Positions compare lexicographically in paint order.
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::AuditError;
@@ -109,6 +109,9 @@ struct Walker<'a> {
pieces: &'a Pieces,
out: Vec<FilledRect>,
ops_seen: usize,
/// Decoded bytes and form entries left for the page, its forms included: spending either
/// fails the audit.
budget: DecodeBudget,
}
impl<'a> Walker<'a> {
@@ -271,8 +274,11 @@ impl<'a> Walker<'a> {
})
.unwrap_or(Matrix::IDENTITY);
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
if !self.budget.enter_form() {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = stream.decoded_strict()?;
let data = self.budget.decode_strict(stream)?;
let ops = parse(&data).ops;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
@@ -304,7 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
/// positions. A page's content streams are walked once, joined, with one graphics state, so
/// state set in an earlier stream is still in force in the later ones. Any content stream that
/// cannot be decoded is an audit failure, never an empty result.
/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`]
/// in all, is an audit failure, never an empty result.
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let contents: Vec<Object> = match page.get(b"Contents") {
@@ -314,15 +321,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
_ => vec![c.clone()],
},
};
let mut budget = DecodeBudget::page();
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(s.decoded_strict()?);
streams.push(budget.decode_strict(s)?);
}
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
+2
View File
@@ -77,6 +77,8 @@ pub enum AuditError {
TooManyOps,
#[error("form XObjects nest deeper than the audit follows")]
TooDeep,
#[error("content walk entered more form XObjects than the audit follows")]
TooManyForms,
#[error("page has a malformed /Annots entry")]
BadAnnots,
#[error(transparent)]
+33
View File
@@ -592,3 +592,36 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
}
}
#[test]
fn repeated_stream_references_share_one_decode_budget() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
// One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents,
// and by 300 draws of a form holding it.
let mut doc = Document::new_empty();
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect();
assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams");
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20]));
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes()));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws");
}
#[test]
fn form_entries_past_the_page_allowance_fail_the_audit() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
// An empty form decodes to no bytes, so only the count of entries bounds drawing it again.
let mut doc = Document::new_empty();
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
let form = doc.add(Stream::from_raw(form_dict, Vec::new()));
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes()));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
}
+172
View File
@@ -0,0 +1,172 @@
//! A shared allowance for operations that decode many streams.
use std::collections::HashSet;
use pdfcraft_filters::FilterError;
use crate::{CosError, Document, ObjRef, Object, Stream};
/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every
/// form XObject it enters, each occurrence counted again.
pub const MAX_PAGE_DECODED: usize = 256 << 20;
/// Form XObjects one page's content walk may enter, each entry counted again.
pub const MAX_PAGE_FORMS: usize = 50_000;
/// What one operation may still decode and enter across all the streams it reads.
///
/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose
/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of
/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is
/// shared by every decode of one operation, so the total decoded bytes and form entries stay
/// under fixed limits however the file repeats its references. A decode that runs past the
/// remaining allowance spends all of it, so a bomb referenced many times is inflated once.
#[derive(Debug)]
pub struct DecodeBudget {
/// The whole allowance, for errors.
limit: usize,
bytes: usize,
forms: usize,
seen: HashSet<ObjRef>,
}
impl DecodeBudget {
/// An allowance of `bytes` decoded bytes and `forms` form entries.
pub fn new(bytes: usize, forms: usize) -> Self {
Self { limit: bytes, bytes, forms, seen: HashSet::new() }
}
/// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
pub fn page() -> Self {
Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS)
}
/// Decoded bytes still allowed.
pub fn remaining(&self) -> usize {
self.bytes
}
/// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
pub fn exhausted(&self) -> bool {
self.bytes == 0 || self.forms == 0
}
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
/// decoding, once the allowance is spent.
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_within_typed)
}
/// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
/// past `max` bytes is an error that charges `max`, the work it took to find out.
pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
self.charge(stream, max, Stream::decoded_within_typed)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
/// the allowance.
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
self.charge(stream, usize::MAX, Stream::decoded_strict_within_typed)
}
fn charge(&mut self, stream: &Stream, max: usize, decode: fn(&Stream, usize) -> Result<Vec<u8>, FilterError>) -> Result<Vec<u8>, CosError> {
if self.bytes == 0 && !stream.raw.is_empty() {
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
}
if max < self.bytes {
let result = decode(stream, max);
let spent = match &result {
Ok(data) => data.len(),
Err(FilterError::LimitExceeded(_)) => max,
Err(_) => 0,
};
self.bytes = self.bytes.saturating_sub(spent);
return result.map_err(|e| CosError::Filter(e.to_string()));
}
match decode(stream, self.bytes) {
Ok(data) => {
self.bytes = self.bytes.saturating_sub(data.len());
Ok(data)
}
Err(FilterError::LimitExceeded(_)) => {
self.bytes = 0;
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
}
Err(e) => Err(CosError::Filter(e.to_string())),
}
}
/// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent:
/// the caller leaves that form out.
pub fn enter_form(&mut self) -> bool {
match self.forms.checked_sub(1) {
Some(left) => {
self.forms = left;
true
}
None => false,
}
}
/// `true` the first time `r` is passed to this budget, for callers that need each
/// referenced stream only once.
pub fn first_visit(&mut self, r: ObjRef) -> bool {
self.seen.insert(r)
}
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
/// place, so the caller chooses between skipping it and giving up.
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
match &*doc.resolve(contents) {
Object::Stream(s) => vec![self.decode(s)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode(s)),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Dict;
#[test]
fn repeated_references_share_one_allowance() {
let mut doc = Document::new_empty();
let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])));
let contents = Object::Array(vec![Object::Ref(bomb); 64]);
let mut budget = DecodeBudget::new(10 << 20, 1);
let decoded = budget.decode_contents(&doc, &contents);
assert_eq!(decoded.len(), 64);
assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10);
let total: usize = decoded.iter().flatten().map(Vec::len).sum();
assert_eq!(total, 10 << 20);
assert!(budget.exhausted());
// A decode past the allowance spends it, so a bomb is inflated at most once more.
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err));
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
assert!(budget.decode_strict(stream).is_err());
assert_eq!(budget.remaining(), 0);
let mut budget = DecodeBudget::new(10 << 20, 1);
assert!(budget.decode_within(stream, 1000).is_err());
assert_eq!(budget.remaining(), (10 << 20) - 1000);
assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20));
assert_eq!(budget.remaining(), (9 << 20) - 1000);
assert!(budget.enter_form());
assert!(!budget.enter_form());
assert!(budget.first_visit(bomb));
assert!(!budget.first_visit(bomb));
}
}
+2
View File
@@ -13,6 +13,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod budget;
mod bytes;
mod document;
mod object;
@@ -21,6 +22,7 @@ mod parser;
mod security;
mod writer;
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
+25 -8
View File
@@ -261,14 +261,14 @@ impl Stream {
/// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an
/// array of names (an indirect reference, say): the data is not unfiltered, so it must not
/// read as decoded.
fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, CosError> {
fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, pdfcraft_filters::FilterError> {
let readable = match self.dict.get(b"Filter") {
None | Some(Object::Null | Object::Name(_)) => true,
Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()),
Some(_) => false,
};
if !readable {
return Err(CosError::Filter("/Filter is not a name or an array of names".into()));
return Err(pdfcraft_filters::FilterError::Unsupported("/Filter is not a name or an array of names".into()));
}
Ok(self.filters())
}
@@ -283,26 +283,39 @@ impl Stream {
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
/// [`MAX_DECODED`]: decoding past `max` bytes is an error.
pub fn decoded_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
self.decoded_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_within`] with the filter error kept typed, so a caller can tell a
/// spent limit from corrupt data.
pub(crate) fn decoded_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
let chain = self.readable_filters()?;
if chain.is_empty() {
return self.raw_within(max);
return self.raw_within_typed(max);
}
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max).map(|(v, _)| v)
}
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
self.decoded_strict_within_typed(MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
}
/// [`Stream::decoded_strict`] bounded by `max`, with the filter error kept typed.
pub(crate) fn decoded_strict_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
let chain = self.readable_filters()?;
if chain.is_empty() {
return self.raw_within(MAX_DECODED);
return self.raw_within_typed(max);
}
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
pdfcraft_filters::decode(&chain, &self.raw, max)
}
/// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded.
/// Any corruption in that first filter is an error.
pub fn decoded_first_filter_strict(&self, max: usize) -> Result<Vec<u8>, CosError> {
let mut chain = self.readable_filters()?;
let mut chain = self.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?;
chain.truncate(1);
if chain.is_empty() {
return self.raw_within(max);
@@ -311,11 +324,15 @@ impl Stream {
}
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
}
fn raw_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
// An empty decoder chain still produces an owned output buffer: check its limit
// before cloning, including streams whose /Crypt filter was applied on load.
if self.raw.len() > max {
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
return Err(pdfcraft_filters::FilterError::LimitExceeded(max));
}
Ok(self.raw.to_vec())
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
let crop = p.crop(doc);
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict);
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
+20 -23
View File
@@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
use crate::text::content_streams;
/// An image drawn on a page.
#[derive(Clone, Debug, PartialEq)]
@@ -73,23 +74,12 @@ struct ClipPath {
even_odd: bool,
}
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
list.into_iter()
.map(|o| {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
};
(o, data)
})
.collect()
/// Image draws one page's inventory lists at most: a form drawn many times from other forms
/// would otherwise multiply a few hundred bytes into millions of records.
pub(crate) const MAX_PAGE_IMAGES: usize = 100_000;
fn too_many_images() -> EditError {
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
@@ -237,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let mut out = Vec::new();
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let pieces = joined(&streams(doc, &p.dict));
let pieces = joined(&content_streams(doc, &p.dict)?);
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
@@ -285,6 +275,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
{
continue;
}
if out.len() >= MAX_PAGE_IMAGES {
return Err(too_many_images());
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
@@ -358,6 +351,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
if out.len() >= MAX_PAGE_IMAGES {
return Err(too_many_images());
}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
@@ -381,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() };
let pieces = joined(&streams(doc, &p.dict));
let mut decode = pdfcraft_cos::DecodeBudget::page();
let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?);
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) };
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
}
@@ -483,7 +480,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let images = page_images(doc, page)?;
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let all = content_streams(doc, &p.dict)?;
let pieces = joined(&all);
let ops = pieces.parse().ops;
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
@@ -531,7 +528,7 @@ pub(crate) fn selectable_images(
excluded: &std::collections::BTreeSet<ObjRef>,
) -> Result<Vec<(usize, PageImage)>, EditError> {
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let all = content_streams(doc, &p.dict)?;
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
@@ -755,7 +752,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
}
let images = page_images(doc, page)?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let all = content_streams(doc, &p.dict)?;
let mut plans = std::collections::BTreeMap::new();
for index in indexes {
let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?;
+93
View File
@@ -6355,3 +6355,96 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
]
);
}
/// A one-page document from the page's /Contents and /Resources, built in code.
fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document {
let mut doc = Document::new_empty();
let pages = doc.add(Object::Null);
let (contents, resources) = build(&mut doc);
let page: Dict = [
(b"Type".to_vec(), Object::Name(b"Page".to_vec())),
(b"Parent".to_vec(), Object::Ref(pages)),
(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
(b"Contents".to_vec(), contents),
(b"Resources".to_vec(), Object::Dict(resources)),
]
.into_iter()
.collect();
let page = doc.add(page);
let tree: Dict = [
(b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
(b"Count".to_vec(), Object::Int(1)),
]
.into_iter()
.collect();
doc.set(pages, Object::Dict(tree));
let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
let catalog = doc.add(catalog);
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
doc
}
#[test]
fn repeated_content_references_share_one_decode_budget() {
// One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents.
let doc = page_with(|doc| {
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
(Object::Array(vec![Object::Ref(spaces); 300]), Dict::new())
});
match text_lines(&doc, 0) {
Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()),
Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"),
}
}
#[test]
fn reading_images_stops_at_the_page_record_limit() {
// 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images
// per level-two form, 160,000 in all, from a few hundred bytes.
let doc = page_with(|doc| {
let image: Dict = [
(b"Subtype".to_vec(), Object::Name(b"Image".to_vec())),
(b"Width".to_vec(), Object::Int(1)),
(b"Height".to_vec(), Object::Int(1)),
(b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())),
(b"BitsPerComponent".to_vec(), Object::Int(8)),
]
.into_iter()
.collect();
let mut inner = doc.add(Stream::from_raw(image, vec![0]));
let draws = "/X Do ".repeat(20);
let resources = |inner: pdfcraft_cos::ObjRef| -> Dict {
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
[(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
};
for _ in 0..3 {
let mut d = resources(inner);
d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect();
inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes()));
}
let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes()));
(Object::Ref(page), resources(inner))
});
if let Ok(images) = crate::reading_images(&doc, 0) {
assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len());
}
}
#[test]
fn reading_spends_one_decode_budget_on_content_and_forms() {
// 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget,
// together they don't, so reading leaves the form out and says so.
let doc = page_with(|doc| {
let mut body = b"%".to_vec();
body.resize(130 << 20, b'x');
let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
let form = doc.add(Stream::flate(form, &body));
let mut content = b"/X Do\n%".to_vec();
content.resize(130 << 20, b'x');
let content = doc.add(Stream::flate(Dict::new(), &content));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
(Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect())
});
assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated);
}
+62 -35
View File
@@ -14,7 +14,7 @@ use std::collections::HashMap;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
@@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
let mut budget = 32_usize << 20;
// Every program decoded counts, usable or not, so aliases of one program can't repeat the work.
let mut decode = DecodeBudget::new(2 * budget, 0);
let mut result = Vec::new();
for (name, font) in fonts.iter().take(128) {
let Some(font) = doc.dict(font) else { continue };
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) else { continue };
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue };
if bytes.len() > budget {
continue;
}
@@ -615,7 +617,16 @@ struct Ts {
unreadable: Option<Rc<str>>,
}
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
/// The page's content streams as listed, each with its decoded data (empty when it doesn't
/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading
/// or rewriting a page from part of its content would lose the rest.
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
content_streams_within(doc, page, &mut DecodeBudget::page())
}
/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on
/// spending.
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -623,15 +634,19 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u
_ => vec![c.clone()],
},
};
list.into_iter()
.map(|o| {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
};
(o, data)
})
.collect()
let mut out = Vec::with_capacity(list.len());
for o in list {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => match budget.decode(s) {
Ok(data) => data,
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
Err(_) => Vec::new(),
},
_ => Vec::new(),
};
out.push((o, data));
}
Ok(out)
}
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
@@ -1284,7 +1299,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let mut carry = Carry::new();
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?;
@@ -1490,15 +1505,23 @@ pub const MAX_FORM_DEPTH: usize = 50;
pub const MAX_FORM_VISITS: usize = 50_000;
/// One page's walk through the form XObjects it draws: how many it entered (the page's
/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread.
#[derive(Default)]
/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether
/// a limit left a form unread.
pub(crate) struct FormBudget {
visits: usize,
/// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past
/// [`MAX_FORM_VISITS`]: what it draws is missing from the walk.
decode: DecodeBudget,
/// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past
/// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk.
pub(crate) truncated: bool,
}
impl FormBudget {
/// The walk of a page's forms, spending what `decode` has left after the page's content.
pub(crate) fn new(decode: DecodeBudget) -> Self {
Self { visits: 0, decode, truncated: false }
}
}
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
/// CTM at the `Do`.
pub(crate) struct FormCall {
@@ -1519,7 +1542,7 @@ pub(crate) fn form_call(
path: &[pdfcraft_cos::ObjRef],
budget: &mut FormBudget,
) -> Option<FormCall> {
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0;
// Once a limit has cut the page short, refusing the rest costs nothing more.
if limited && budget.truncated {
return None;
@@ -1546,13 +1569,16 @@ pub(crate) fn form_call(
});
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let data = match budget.decode.decode(s) {
Ok(data) => data,
Err(_) if budget.decode.remaining() == 0 => {
budget.truncated = true;
return None;
}
Err(_) => Vec::new(),
};
budget.visits = budget.visits.saturating_add(1);
Some(FormCall {
obj,
data: s.decoded().unwrap_or_default(),
resources: own.unwrap_or_else(|| resources.clone()),
ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)),
})
Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) })
}
/// The lines a page shows as it reads, including text drawn by form XObjects (which
@@ -1612,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
}
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() };
let mut decode = DecodeBudget::page();
let streams = content_streams_within(doc, &p.dict, &mut decode)?;
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) };
let mut carry = Carry::new();
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
@@ -2235,7 +2262,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
@@ -2440,7 +2467,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
@@ -2486,10 +2513,10 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, Edi
pub struct ReadingBlocks {
/// The paragraphs, as [`reading_blocks`] returns them.
pub blocks: Vec<TextBlock>,
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than
/// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their
/// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
/// their images too.
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than
/// [`MAX_FORM_VISITS`], or more content and forms than one page's decode budget. Reading
/// leaves those out to stay bounded on hostile files, so their text is missing from
/// `blocks`; [`crate::reading_images`] walks the same forms and misses their images too.
pub truncated: bool,
}
@@ -2661,7 +2688,7 @@ fn rewrite_block_from(
{
return rewrite_styled_block(doc, page, &b, &lines, text, style);
}
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
// Explicit line breaks stay (#1443); within a line, runs of white space become one space.
@@ -3044,7 +3071,7 @@ pub(crate) fn selectable_blocks(
excluded: &std::collections::BTreeSet<pdfcraft_cos::ObjRef>,
) -> Result<Vec<(usize, TextBlock)>, EditError> {
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines)
.into_iter()
@@ -3133,7 +3160,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
return Ok(());
}
let p = page_dict(doc, page)?;
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let mut selected = std::collections::BTreeSet::new();
+1 -1
View File
@@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block(
row_ranges.push(row_start..row_start + row.len());
rows.push(row);
}
let streams = content_streams(doc, &p.dict);
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
+27 -2
View File
@@ -14,7 +14,7 @@
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
/// How the bytes of a string map to codes.
#[derive(Clone, Debug, PartialEq)]
@@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool {
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
}
/// The largest embedded sfnt program read for the editor.
const MAX_SFNT: usize = 16 << 20;
/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
/// each program decoded is charged whether or not it turns out to be usable.
pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?;
@@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue };
let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
continue;
@@ -2257,4 +2266,20 @@ mod encoding_tests {
assert_eq!(m.text_of(0x61).as_deref(), Some("a"));
assert_eq!(m.decode(b"abc"), "abc");
}
#[test]
fn unusable_programs_still_spend_the_decode_budget() {
// 128 resources naming one 1 MiB program that isn't a font: each decode is charged,
// so an enumeration stops decoding once the shared budget is spent.
let mut doc = Document::new_empty();
let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20]));
let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect();
let descriptor = doc.add(descriptor);
let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect();
let mut budget = DecodeBudget::new(64 << 20, 0);
for _ in 0..128 {
assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none());
}
assert_eq!(budget.remaining(), 0);
}
}
+4 -3
View File
@@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec<String> {
}
}
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option<FieldAction> {
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
let a = doc.resolve(o);
let d = a.as_dict()?;
Some(match d.name(b"S")? {
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()),
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()),
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
@@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut scripts = crate::script_budget();
for t in Trigger::ALL {
let o = match t.key() {
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
Some(k) => aa.get(k).cloned(),
};
// An action chain (/Next) counts as its first action here.
if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) {
if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) {
out.push((t, a));
}
}
+35 -17
View File
@@ -14,7 +14,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString};
mod actions;
pub mod af;
@@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object {
const MAX_LAYER_STATE: usize = 1024;
/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget).
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::ButtonAction> {
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option<af::ButtonAction> {
// A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action):
// clicking it picks a picture, as `buttonImportIcon()` does.
if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
@@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::But
let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false)));
af::ButtonAction::SetLayers { changes, preserve_rb }
}
b"JavaScript" => af::button_script(&script(doc, &action)?),
b"JavaScript" => af::button_script(&script(doc, &action, scripts)?),
_ => return None,
})
}
@@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
(!parts.is_empty()).then(|| parts.join("."))
}
/// The JavaScript of an action dictionary (`/JS` string or stream).
fn script(doc: &Document, action: &Object) -> Option<String> {
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
/// in all, however many actions name the same stream.
const MAX_SCRIPT_BYTES: usize = 64 << 20;
/// A budget for the script streams of one reading of the form.
fn script_budget() -> DecodeBudget {
DecodeBudget::new(MAX_SCRIPT_BYTES, 0)
}
/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against
/// `budget`, and is `None` once it is spent.
fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option<String> {
let a = doc.resolve(action);
let js = a.as_dict()?.get(b"JS")?.clone();
match &*doc.resolve(&js) {
Object::String(s) => Some(s.to_text()),
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only
/// widget's when the field has none).
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions {
let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| {
(widgets.len() == 1)
.then(|| doc.get(widgets[0]))
@@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
});
let mut out = af::Actions::default();
let Some(aa) = aa else { return out };
let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a));
let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts));
if let Some(f) = js(b"F") {
match af::parse_format(&f) {
Some(fm) => out.format = fm,
@@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() };
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
@@ -604,7 +614,7 @@ fn adopt_page_fields(
pages: &[ObjRef],
base: &Inherited,
page_of: &std::collections::HashMap<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
seen: &mut Seen,
out: &mut Vec<Field>,
) {
for p in pages {
@@ -613,7 +623,7 @@ fn adopt_page_fields(
let Some(annots) = annots.as_array() else { continue };
for e in annots {
let Some(r) = e.as_ref() else { continue };
if seen.contains(&r) {
if seen.fields.contains(&r) {
continue;
}
let obj = doc.get(r);
@@ -624,7 +634,7 @@ fn adopt_page_fields(
if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") {
continue;
}
if let Some(root) = unlisted_root(doc, r, seen) {
if let Some(root) = unlisted_root(doc, r, &seen.fields) {
walk(doc, root, base, page_of, seen, out, 0);
}
}
@@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
/// What one reading of the form's fields has used: the field dictionaries read, and the script
/// budget their actions share.
struct Seen {
fields: std::collections::HashSet<ObjRef>,
scripts: DecodeBudget,
}
fn walk(
doc: &Document,
r: ObjRef,
parent: &Inherited,
page_of: &std::collections::HashMap<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
seen: &mut Seen,
out: &mut Vec<Field>,
depth: usize,
) {
if depth > 64 || !seen.insert(r) {
if depth > 64 || !seen.fields.insert(r) {
return;
}
let obj = doc.get(r);
@@ -808,9 +825,10 @@ fn walk(
return;
}
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
let actions = actions_of(doc, d, &widget_refs);
let button =
(inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
.then(|| button_action(doc, d, &widget_refs, &mut seen.scripts))
.flatten();
let ff = inh.ff.unwrap_or(0);
let kind = match inh.ft.as_deref() {
Some(b"Tx") => FieldKind::Text,
+2 -1
View File
@@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() };
let mut out: Vec<(String, String)> = Vec::new();
let mut scripts = crate::script_budget();
// Kids stay unresolved on the stack, and each node is read once: a node listed many times,
// or among its own kids, must not multiply the work.
let mut stack = vec![tree];
@@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
Object::String(s) => s.to_text(),
_ => continue,
};
if let Some(js) = crate::script(doc, v) {
if let Some(js) = crate::script(doc, v, &mut scripts) {
out.push((key, js));
}
}
+40
View File
@@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
assert_eq!(walk.next(), None);
assert_eq!(page_refs(&doc), [page]);
}
/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream.
fn repeated_script_references() -> Document {
use pdfcraft_cos::Stream;
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
let mut doc = Document::new_empty();
let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20]));
let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))]));
let fields: Vec<Object> = (0..300)
.map(|i| {
Object::Ref(doc.add(dict(vec![
(b"FT", Object::Name(b"Tx".to_vec())),
(b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))),
(b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())),
(b"AA", dict(vec![(b"C", Object::Ref(action))])),
])))
})
.collect();
let names: Vec<Object> =
(0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect();
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
doc
}
#[test]
fn repeated_script_references_share_one_decode_budget() {
let doc = repeated_script_references();
let limit = MAX_SCRIPT_BYTES;
let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum();
assert!(named > 0 && named <= limit, "{named} bytes of document scripts");
let all = fields(&doc);
assert_eq!(all.len(), 300);
let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum();
assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts");
}
+4 -2
View File
@@ -18,7 +18,7 @@
//! drops them.
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample};
use crate::{OptimizeError, Report, Stage};
@@ -232,7 +232,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) ->
/// Merge the fragment runs of one page; returns how many fragments were merged.
fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
let parsed = parse(&content_bytes(doc, &contents));
// A page whose content isn't all known is left alone: rewriting it would drop the rest.
let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) };
let parsed = parse(&data);
if parsed.skipped > 0 {
return Ok(0);
}
+50 -21
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
@@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
None
}
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
match &*doc.resolve(contents) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => {
let pieces: Vec<_> = a
.iter()
.filter_map(|c| match &*doc.resolve(c) {
Object::Stream(s) => Some(s.decoded().unwrap_or_default()),
_ => None,
})
.collect();
Pieces::join(&pieces).data().to_vec()
}
_ => Vec::new(),
}
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
/// doesn't decode or the budget runs out: the content is not all known.
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
Some(Pieces::join(&pieces).data().to_vec())
}
/// Forms nested deeper than this aren't followed.
@@ -57,6 +47,8 @@ struct Walk<'a> {
/// Forms walked at least once.
seen: HashSet<ObjRef>,
rewalks: usize,
/// Decoded bytes left for the page being walked.
budget: DecodeBudget,
/// False once a use may have been missed (depth or work limit).
complete: bool,
out: HashMap<ObjRef, f64>,
@@ -135,23 +127,46 @@ impl Walk<'_> {
}
self.rewalks += 1;
}
if !self.budget.enter_form() {
self.complete = false;
return;
}
let Ok(data) = self.budget.decode(s) else {
self.complete = false;
return;
};
self.path.push(r);
self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner);
self.content(&data, own.as_ref().unwrap_or(resources), inner);
self.path.pop();
}
}
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
///
/// If forms nest too deeply or are drawn too many times to measure every use, every image is
/// given `INFINITY` (never resampled), since its largest use may be among those missed.
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
/// among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() };
let mut walk = Walk {
doc,
path: Vec::new(),
walked: HashSet::new(),
seen: HashSet::new(),
rewalks: 0,
budget: DecodeBudget::page(),
complete: true,
out: HashMap::new(),
};
for p in pages {
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
let Some(contents) = d.get(b"Contents") else { continue };
let res = page_resources(doc, *p).unwrap_or_default();
walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
walk.budget = DecodeBudget::page();
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
walk.complete = false;
continue;
};
walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
}
if !walk.complete {
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
@@ -471,3 +486,17 @@ mod dimension_tests {
assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream};
#[test]
fn repeated_content_references_share_one_decode_budget() {
let mut doc = Document::new_empty();
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let contents = Object::Array(vec![Object::Ref(stream); 300]);
let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page());
assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300));
}
}
+13
View File
@@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() {
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
}
/// Each form entry counts against the page's form allowance, even for a form that decodes to
/// nothing and so costs no decoded bytes.
#[test]
fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() {
let mut doc = Document::new_empty();
let img = image(&mut doc, 1200, 1200, 3, false, None);
let empty = form(&mut doc, None, "");
let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1);
page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}"));
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
}
#[test]
fn image_resolution_keeps_comments_across_content_pieces() {
for ending in ["", "\n", "\r", "\r\n"] {
+4 -13
View File
@@ -1354,19 +1354,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
// Content: the page's streams, decoded and joined.
let mut content = Vec::new();
if let Some(c) = own.get(b"Contents") {
let list = match &*src.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
};
for o in list {
if let Object::Stream(s) = &*src.resolve(&o) {
content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?);
content.push(b'\n');
}
}
}
let content = match own.get(b"Contents") {
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?,
None => Vec::new(),
};
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
let resources = match d.get(b"Resources") {
Some(r) => copier.copy_value(dst, r, None)?,
+13 -20
View File
@@ -8,7 +8,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object};
use crate::OrganizeError;
@@ -223,23 +223,16 @@ impl Pruner<'_> {
}
}
/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a
/// stream can't be decoded: the page may draw names that can't be seen.
fn content_bytes(doc: &Document, contents: &Object) -> Option<Vec<u8>> {
match &*doc.resolve(contents) {
Object::Stream(s) => s.decoded().ok(),
Object::Array(a) => {
let mut out = Vec::new();
for c in a {
if let Object::Stream(s) = &*doc.resolve(c) {
out.extend(s.decoded().ok()?);
out.push(b'\n');
}
}
Some(out)
}
_ => Some(Vec::new()),
/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded
/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget
/// runs out: the page may draw names that can't be seen.
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
let mut out = Vec::new();
for data in DecodeBudget::page().decode_contents(doc, contents) {
out.extend(data?);
out.push(b'\n');
}
Ok(out)
}
/// The resources of a page (as `walk` reports them) with their owner.
@@ -321,9 +314,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue };
let Some(contents) = pd.get(b"Contents").cloned() else { continue };
match content_bytes(pruner.doc, &contents) {
Some(data) if data.is_empty() => {}
Some(data) => pruner.content(&data, &res_dict, owner, 0),
None => {
Ok(data) if data.is_empty() => {}
Ok(data) => pruner.content(&data, &res_dict, owner, 0),
Err(_) => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
+40
View File
@@ -3132,3 +3132,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() {
assert!(walk.next().is_none());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);
}
/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk).
fn repeated_contents() -> Document {
use pdfcraft_cos::{Dict, Stream};
let mut doc = Document::new_empty();
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let pages = doc.add(Object::Null);
let page: Dict = [
(b"Type".to_vec(), Object::Name(b"Page".to_vec())),
(b"Parent".to_vec(), Object::Ref(pages)),
(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])),
]
.into_iter()
.collect();
let page = doc.add(page);
let tree: Dict = [
(b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
(b"Count".to_vec(), Object::Int(1)),
]
.into_iter()
.collect();
doc.set(pages, Object::Dict(tree));
let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
let catalog = doc.add(catalog);
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
doc
}
#[test]
fn repeated_content_references_share_one_decode_budget() {
let src = repeated_contents();
let mut dst = Document::new_empty();
if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) {
let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") };
let len = s.decoded().unwrap().len();
assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes");
}
}
+30 -21
View File
@@ -18,7 +18,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
pub mod range;
pub mod spool;
@@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool {
let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default();
let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut score = 0i64;
text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score);
let mut budget = DecodeBudget::page();
// Content past the budget is left out: the answer is a guess from the text read so far.
let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default();
text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget);
score > 0
}
fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) {
fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) {
let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new());
for op in pdfcraft_content::parse(data).ops {
match op.op.as_slice() {
@@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth:
}
let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default();
let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone());
if let Ok(body) = form.decoded() {
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score);
if !budget.enter_form() {
return;
}
if let Ok(body) = budget.decode(form) {
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget);
}
}
_ => {}
@@ -676,24 +682,22 @@ fn n(v: f64) -> String {
if s == "-0" { "0".into() } else { s.into() }
}
fn decoded_contents(doc: &Document, page: &Dict) -> Vec<u8> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
/// The page's content streams decoded against `budget` and joined; streams that don't decode
/// are left out. `None` when the budget ran out, so part of the content is missing.
fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) };
let mut out = Vec::new();
for o in list {
if let Object::Stream(s) = &*doc.resolve(&o)
&& let Ok(d) = s.decoded()
{
out.extend_from_slice(&d);
out.push(b'\n');
for data in budget.decode_contents(doc, contents) {
match data {
Ok(data) => {
out.extend_from_slice(&data);
out.push(b'\n');
}
Err(_) if budget.remaining() == 0 => return None,
Err(_) => {}
}
}
out
Some(out)
}
/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that
@@ -782,7 +786,12 @@ fn page_form(
let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?;
let crop = page.crop(doc);
let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) };
let mut body = if content == Content::FormFieldsOnly {
Vec::new()
} else {
decoded_contents(doc, &page.dict, &mut DecodeBudget::page())
.ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))?
};
// Wrap the page content so a state it leaves behind can't affect the annotations.
if !body.is_empty() {
let mut wrapped = b"q\n".to_vec();
+39
View File
@@ -888,3 +888,42 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() {
}
mod appearances;
#[test]
fn repeated_content_references_share_one_decode_budget() {
let mut doc = Document::new_empty();
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
let data = decoded_contents(&doc, &page, &mut DecodeBudget::page());
assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300));
}
/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form
/// entries from a few hundred bytes.
fn form_fanout(doc: &mut Document) -> Dict {
let draws = "/X Do ".repeat(20);
let mut form = |content: &str, inner: Option<ObjRef>| {
let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
if let Some(inner) = inner {
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
d.set(b"Resources".to_vec(), Object::Dict(res));
}
doc.add(Stream::from_raw(d, content.as_bytes().to_vec()))
};
let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None);
for _ in 0..3 {
inner = form(&draws, Some(inner));
}
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
[(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
}
#[test]
fn text_direction_stops_at_the_form_budget() {
let mut doc = Document::new_empty();
let res = form_fanout(&mut doc);
let mut score = 0;
text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page());
assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}");
}
+27 -3
View File
@@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream};
use crate::{RedactError, Report, image, page_streams, tags};
use pdfcraft_fonts::pdf::Metrics;
@@ -106,10 +106,16 @@ pub(crate) struct Scope<'a> {
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
used_names: Vec<Vec<u8>>,
depth: usize,
/// Form entries and decoded bytes left for this page, its content streams already charged:
/// shared forms drawn from other shared forms would otherwise multiply the work with every
/// level.
budget: DecodeBudget,
}
impl<'a> Scope<'a> {
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self {
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
/// content streams were decoded.
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
Scope {
rects,
mode,
@@ -124,6 +130,7 @@ impl<'a> Scope<'a> {
fonts: HashMap::new(),
used_names: Vec::new(),
depth: 0,
budget,
}
}
@@ -1272,8 +1279,25 @@ fn xobject(
return Some(None);
}
// Strict, like page content: a Form read only in part counts as undecodable.
let data = match s.decoded_strict() {
let data = if scope.budget.enter_form() {
scope.budget.decode_strict(s)
} else {
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
};
let data = match data {
Ok(d) => d,
// Past the page's budget nothing more is read: apply refuses the page, and
// verification counts the form as content that may remain.
Err(e) if scope.budget.exhausted() => {
if scope.mode == Mode::Apply {
out.error = Some(e);
} else if scope.everywhere() {
scope.layer_blocks += 1;
} else {
out.residue += 1;
}
return None;
}
Err(_) if scope.mode == Mode::Apply => {
scope.report.forms_removed += 1;
out.replaced.push(name);
+20 -6
View File
@@ -227,8 +227,20 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
(out, unreadable)
}
/// A page's content streams: (the references or inline objects as listed, their decoded data).
/// A page's content streams: (the references or inline objects as listed, their decoded data),
/// decoded within one page's [`pdfcraft_cos::DecodeBudget`].
pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page())
}
/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on
/// spending.
pub(crate) fn page_streams_within(
doc: &Document,
page: &Dict,
index: usize,
budget: &mut pdfcraft_cos::DecodeBudget,
) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -241,7 +253,7 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<
match &*doc.resolve(o) {
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
// still draws in viewers that read further; anything not read can't be cleaned.
Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Null => data.push(Vec::new()),
_ => return Err(RedactError::Unreadable(index + 1)),
}
@@ -394,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
// 1. Content.
let (list, data) = page_streams(doc, &page.dict, pi)?;
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let out = {
let mut scope = Scope::new(&rects, Mode::Apply, &mut report);
let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget);
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
};
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
@@ -599,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
for &(pi, page_marks) in &groups {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
let (list, data) = page_streams(doc, &page.dict, pi)?;
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
// The overlay stream draws text of its own: the overlay label is allowed.
let original: Vec<Vec<u8>> =
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut scratch = Report::default();
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch);
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget);
residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue;
}
if residue > 0 {
+4 -3
View File
@@ -8,7 +8,7 @@ use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::interp::{Mode, Scope, process};
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams};
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within};
/// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let n = pdfcraft_model::pages(doc).len();
for pi in 0..n {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
let Ok((list, data)) = page_streams(doc, &page.dict, pi) else {
let mut budget = pdfcraft_cos::DecodeBudget::page();
let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else {
if write {
return Err(RedactError::Unreadable(pi + 1));
}
@@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut report = Report::default();
let mode = if write { Mode::Apply } else { Mode::Verify };
let mut scope = Scope::new(&[], mode, &mut report);
let mut scope = Scope::new(&[], mode, &mut report, budget);
if text {
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
+51 -2
View File
@@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String {
/// Glyphs (and inline images) of page `page` under `rects` (the verifier's count).
fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let (_, data) = page_streams(doc, &p.dict, page).unwrap();
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap();
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut rep = Report::default();
let mut scope = Scope::new(rects, Mode::Verify, &mut rep);
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget);
process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue
}
@@ -2478,6 +2479,23 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() {
assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws");
}
#[test]
fn form_entries_share_one_budget_per_page() {
// 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a
// few hundred bytes.
let draws = "/X Do ".repeat(20);
let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")];
for inner in 7..10 {
objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes()));
}
let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs);
let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]);
assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted");
// Applying refuses the page rather than leave forms past the budget unread.
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
assert!(apply(&mut doc, None).is_err());
}
/// A name tree whose levels each list the next level twice: a walk that follows every path
/// visits 2^levels nodes.
#[test]
@@ -2696,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() {
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid");
}
/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a
/// 130 MiB form are each under it, together past it.
#[test]
fn content_and_forms_share_one_budget_per_page() {
use pdfcraft_cos::{Dict, Stream};
let mut doc = one_page(b"", "", vec![]);
let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec();
content.resize(130 << 20, b'x');
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
let mut body = b"%".to_vec();
body.resize(130 << 20, b'x');
let form = doc.add(Object::Stream(Stream::flate(form, &body)));
doc.update_dict(ObjRef::new(3, 0), |d| {
let mut xobjects = Dict::new();
xobjects.set(b"X".to_vec(), Object::Ref(form));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(res));
d.set(b"Contents".to_vec(), Object::Ref(content));
})
.unwrap();
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read");
}