mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:17:44 -06:00
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:
+25
-16
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()))?;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user