M6: forms decodes field values, renamed calculations and detected pages within shared budgets (#6132)

* M6: forms decodes values, renamed calculations and detected pages within shared budgets

Part of #5755: the forms crate's four unbudgeted stream reads go through a
`DecodeBudget`, so an operation's decoding is bounded however often the file
names the same stream.

- A reading of the form decodes its fields' value streams (/V, /DV) within one
  64 MiB budget, as it already does their scripts. Each field got a fresh
  1 GiB allowance, so 300 fields naming one stream decoded it 300 times.
- Renaming a field rewrites the calculations that read it within one script
  budget; past it, a calculation is left as it is, like one that won't decode.
- Field detection reads a page's content within one page's allowance
  (`DecodeBudget::page`), as the content walks do, however often /Contents
  lists a stream.
- The PDF/A declaration is read from at most 8 MiB of XMP, as preflight's
  Standards panel reads it, and only once /NeedAppearances isn't already set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: the forms README says which metadata declares PDF/A

declares_pdfa reads pdfaid:part from XMP metadata that decodes within
8 MiB, as preflight's Standards panel reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: a test pins that metadata without pdfaid:part declares no PDF/A

cargo-mutants found `==` turned to `!=` in declares_pdfa's search unpinned:
no case had metadata within the cap that declares nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: field detection reads operators one at a time; the PDF/A check reads XMP as preflight does

From the Resources review of 66415d737:
- page_shapes collected every parsed operator, about 80 bytes for each
  decoded byte of dense content, so a page within its 256 MiB allowance
  could take some 20 GB. It now reads them one at a time
  (pdfcraft_content::ops) and drops the pieces once joined.
- declares_pdfa decoded through a DecodeBudget, which counts every filter
  stage against the 8 MiB cap, so XMP stored through a chain of filters
  stopped declaring PDF/A while preflight's Standards panel, which caps
  each stage, still read it. It reads the metadata as preflight does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M6: say what declares PDF/A to forms, and why its metadata read stays direct

From the Legal-forms review of c00e3d2fb: the README and doc comments said
forms reads the declaration "as preflight's Standards panel reads it". The
two share the 8 MiB cap, applied to each filter stage, but forms matches the
bytes pdfaid:part and preflight the identifier by its namespace, as on main.
The sweep's direct-/DecodeParms hit is answered at the site: preflight and
organize read metadata the same way, and the readers move together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* forms: rename a shared calculation once; keep unread defaults on Clear form; cap q in detection

- A Calculate action that several fields share by reference is read and
  rewritten once, in place, so every calculation reads the new name and
  the script is decoded once, not skipped past the budget.
- Clear form leaves a field alone when its /DV is a stream the value
  budget didn't reach, instead of emptying it.
- Field detection keeps at most 256 saved graphics states; deeper q are
  counted so each Q still restores its own.
- The dense-content test checks that the budget is spent; exhausted() was
  true from the start with no form visits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* forms: restore the matrix past the save cap; reset a value that wasn't read

- Field detection keeps the matrix of the first save past MAX_SAVES and
  restores it when the last of them closes. A shape painted after a Q
  that would have undone a cm inside those saves is dropped, as the
  matrix there isn't known, instead of being reported scaled.
- Clear form skips a field only when its value is known: one whose /V
  is a stream the value budget didn't reach is reset, not kept as if it
  equalled an empty default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* forms: keep detection's saved states in a Saves type

shapes_within went past clippy's cognitive complexity limit (32/30) with
the save-cap tracking inline. The same logic now lives in Saves (save,
restore, moved, unknown).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-04 13:51:40 +01:00
1 parent d579f26de3
commit c0cd40f2e1
7 files changed
+298 -20

No files matched your search

+4 -1
View File
@@ -50,7 +50,10 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is
marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn)
and the form sets `/NeedAppearances`, so other viewers draw the field from its value with fonts
that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`).
that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`:
the bytes `pdfaid:part` in XMP metadata each filter stage of which decodes within 8 MiB, the cap
preflight's Standards panel reads it with; preflight reads the identifier by its namespace, so a
packet that gives that namespace another prefix declares PDF/A there and not here).
`undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and
flattening refuses to make that `?` permanent.
- Displaying a form that leaves its appearances to the viewer (`/NeedAppearances`, or a text or
+13 -2
View File
@@ -212,13 +212,24 @@ impl FieldAppearanceCache {
}
}
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`).
/// The most each filter stage of the XMP metadata stream may decode for its PDF/A declaration to
/// be read, the cap preflight's Standards panel reads it with: past it, metadata declares nothing
/// there either.
pub(crate) const MAX_XMP_BYTES: usize = 8 << 20;
/// Whether the document's XMP metadata declares PDF/A conformance (the bytes `pdfaid:part`).
/// Metadata a filter stage of which decodes past [`MAX_XMP_BYTES`] doesn't.
pub fn declares_pdfa(doc: &Document) -> bool {
let Some(root) = doc.root() else { return false };
let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false };
let m = doc.resolve(&m);
match &*m {
Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")),
// Not a `DecodeBudget`, which counts every stage against one allowance: preflight bounds
// each stage, and the two must agree on metadata stored through a chain of filters.
// `/DecodeParms` is read only when direct, as preflight and organize read metadata too:
// PDF/A asks for it unfiltered, so only a file that breaks that reaches parameters here,
// and the readers go to `with_direct_parms` together or they disagree.
Object::Stream(s) => s.decoded_within(MAX_XMP_BYTES).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")),
_ => false,
}
}
+15 -2
View File
@@ -583,10 +583,15 @@ fn apply_late_widget_props(
}
/// Name field `old` `new` in each field's Calculate script that reads it
/// ([`crate::af::rename_in_calculation`]).
/// ([`crate::af::rename_in_calculation`]). Script streams share one budget, as a reading of the
/// form's scripts does, however many fields name the same stream; past it, a script is left as
/// it is, like one that won't decode. A Calculate action several fields share by reference is
/// read and rewritten once, in place, so they all read the new name.
fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> {
let aa_of =
|doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned());
let mut scripts = crate::script_budget();
let mut shared = std::collections::HashSet::new();
for f in all {
// The script is the field's, or its only widget's when the field has none, as
// `actions_of` reads it.
@@ -595,12 +600,16 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st
_ => None,
};
let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue };
let by_ref = aa.get(b"C").and_then(Object::as_ref);
if by_ref.is_some_and(|r| !shared.insert(r)) {
continue;
}
let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue };
let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() {
Some(Object::String(s)) => s.to_text(),
// A stream that isn't UTF-8 reads with replacement characters, which must not be
// written back in place of what it holds.
Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) {
Some(Object::Stream(s)) => match scripts.decode(s).ok().and_then(|b| String::from_utf8(b).ok()) {
Some(js) => js,
None => continue,
},
@@ -608,6 +617,10 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st
};
let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue };
action.set(b"JS".to_vec(), PdfString::text(&js));
if let Some(r) = by_ref {
doc.update_dict(r, |d| *d = action)?;
continue;
}
aa.set(b"C".to_vec(), Object::Dict(action));
doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?;
}
+80 -6
View File
@@ -6,7 +6,7 @@
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{Document, Object};
use pdfcraft_cos::{DecodeBudget, Document, Object};
pub mod raster;
@@ -49,6 +49,60 @@ pub struct Candidate {
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
const MAX_RECT_POINTS: usize = 16;
/// Graphics states saved (`q`) and kept at once while detecting; deeper saves are counted, not kept.
const MAX_SAVES: usize = 256;
/// The graphics states a page saves (`q`) as detection follows its matrix: at most [`MAX_SAVES`]
/// kept, deeper ones counted so each `Q` still closes its own. Of those not kept: the matrix when
/// the first was made, which closing the last restores; the shallowest a `cm` changed the matrix
/// in; and whether a `Q` has closed that one, so the matrix isn't known until the last closes.
struct Saves {
kept: Vec<Matrix>,
lost: usize,
lost_base: Matrix,
moved_at: usize,
unknown: bool,
}
impl Default for Saves {
fn default() -> Self {
Saves { kept: Vec::new(), lost: 0, lost_base: Matrix::IDENTITY, moved_at: usize::MAX, unknown: false }
}
}
impl Saves {
fn save(&mut self, ctm: Matrix) {
if self.kept.len() < MAX_SAVES && self.lost == 0 {
self.kept.push(ctm);
} else {
if self.lost == 0 {
self.lost_base = ctm;
}
self.lost = self.lost.saturating_add(1);
}
}
fn restore(&mut self, ctm: &mut Matrix) {
if self.lost == 0 {
*ctm = self.kept.pop().unwrap_or(*ctm);
return;
}
self.lost -= 1;
if self.lost == 0 {
(*ctm, self.moved_at, self.unknown) = (self.lost_base, usize::MAX, false);
} else if self.lost < self.moved_at {
self.unknown = true;
}
}
/// A `cm` changed the matrix at the current depth.
fn moved(&mut self) {
if self.lost > 0 {
self.moved_at = self.moved_at.min(self.lost);
}
}
}
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
@@ -125,7 +179,15 @@ impl DetectionPath {
}
}
/// The shapes page `page`'s content draws, its streams decoded within one page's allowance
/// ([`DecodeBudget::page`]), however often `/Contents` lists the same stream.
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
shapes_within(doc, page, &mut DecodeBudget::page())
}
/// [`page_shapes`], decoding against `budget`: a stream `/Contents` lists again is charged again,
/// and one past what is left reads as empty.
pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudget) -> Shapes {
let mut out = Shapes::default();
let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out };
let list: Vec<Object> = match p.dict.get(b"Contents").map(|c| doc.resolve(c)) {
@@ -139,24 +201,36 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let mut pieces = Vec::new();
for o in list {
if let Object::Stream(s) = &*doc.resolve(&o) {
pieces.push(s.decoded().unwrap_or_default());
pieces.push(budget.decode(s).unwrap_or_default());
}
}
let mut ctm = Matrix::IDENTITY;
let mut stack = Vec::new();
let mut saves = Saves::default();
let mut path_boxes: Vec<[f64; 4]> = Vec::new();
let mut subpaths: Vec<DetectionPath> = Vec::new();
let mut path = DetectionPath::default();
let mut cur: Option<[f64; 2]> = None;
for op in Pieces::join(&pieces).parse().ops {
let joined = Pieces::join(&pieces);
drop(pieces);
// An operator at a time: dense content (`n n n …`) parsed whole holds about 80 bytes for each
// byte it decoded.
for op in pdfcraft_content::ops(joined.data()) {
match op.op.as_slice() {
b"q" => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(ctm),
b"q" => saves.save(ctm),
b"Q" => saves.restore(&mut ctm),
b"cm" => {
if let Some(m) = op.nums::<6>() {
ctm = Matrix(m).then(&ctm);
saves.moved();
}
}
// Painted where the matrix isn't known: dropped, as `n` drops a path.
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" if saves.unknown => {
path_boxes.clear();
subpaths.clear();
path = DetectionPath::default();
cur = None;
}
b"re" => {
path.finish(&mut subpaths);
if let Some([x, y, w, h]) = op.nums::<4>() {
+19 -7
View File
@@ -192,11 +192,17 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Decode
out
}
fn values_of(doc: &Document, o: Option<&Object>) -> Vec<String> {
/// Text streams of field values (`/V`, `/DV`) one reading of the form may decode in all, however
/// many fields name the same stream.
pub(crate) const MAX_VALUE_BYTES: usize = 64 << 20;
/// A field's value or default value (`/V`, `/DV`). A text stream is decoded against `budget`, and
/// reads as no value once it is spent, as one that won't decode does.
fn values_of(doc: &Document, o: Option<&Object>, budget: &mut DecodeBudget) -> Vec<String> {
let Some(o) = o else { return Vec::new() };
match &*doc.resolve(o) {
Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(),
Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(),
Object::Stream(s) => budget.decode(s).ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(),
other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(),
}
}
@@ -345,7 +351,12 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() };
let mut seen = Seen {
fields: std::collections::HashSet::new(),
widgets: std::collections::HashSet::new(),
scripts: script_budget(),
values: DecodeBudget::new(MAX_VALUE_BYTES, 0),
};
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() {
@@ -416,13 +427,14 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<Obj
}
}
/// What one reading of the form's fields has used: the field and widget dictionaries read, and
/// the script budget their actions share.
/// What one reading of the form's fields has used: the field and widget dictionaries read, the
/// script budget their actions share, and the budget their value streams share.
struct Seen {
fields: std::collections::HashSet<ObjRef>,
/// A widget belongs to the first field that lists it, however often it is listed.
widgets: std::collections::HashSet<ObjRef>,
scripts: DecodeBudget,
values: DecodeBudget,
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
@@ -549,8 +561,8 @@ fn walk(
name: inh.name.clone(),
obj: r,
kind,
value: values_of(doc, inh.v.as_ref()),
default: values_of(doc, inh.dv.as_ref()),
value: values_of(doc, inh.v.as_ref(), &mut seen.values),
default: values_of(doc, inh.dv.as_ref(), &mut seen.values),
flags: ff,
max_len: inh.max_len,
options,
+143
View File
@@ -3575,6 +3575,149 @@ fn repeated_script_references_share_one_decode_budget() {
assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts");
}
/// An unfiltered stream of `body`, as an object of [`document`].
fn raw_stream(body: &str) -> String {
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())
}
/// 300 fields whose value is one 1 MiB text stream: a reading of the form decodes their values
/// within one budget, not 300 MiB.
#[test]
fn repeated_value_streams_share_one_decode_budget() {
let fields_list: String = (0..300).map(|i| format!("{} 0 R ", i + 3)).collect();
let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"x".repeat(1 << 20))];
objs.extend((0..300).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R /DV 2 0 R >>")));
let all = fields(&document(&objs));
assert_eq!(all.len(), 300);
let read: usize = all.iter().flat_map(|f| f.value.iter().chain(&f.default)).map(String::len).sum();
assert!(read > 0 && read <= crate::read::MAX_VALUE_BYTES, "{read} bytes of values");
assert_eq!(all[0].value, ["x".repeat(1 << 20)], "the first field still reads its value");
}
/// Clear form leaves a field as it is when its default is a stream the value budget didn't reach:
/// 70 fields typed in, whose default is one 1 MiB text stream, keep a value, the first ones
/// their default and the rest what was typed, none of them emptied.
#[test]
fn clear_form_keeps_a_field_whose_default_was_not_read() {
let n = 70;
let fields_list: String = (0..n).map(|i| format!("{} 0 R ", i + 3)).collect();
let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"d".repeat(1 << 20))];
objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V (typed) /DV 2 0 R >>")));
let mut doc = document(&objs);
reset(&mut doc, None).unwrap();
let values: Vec<String> = (0..n)
.map(|i| {
let field = doc.get(ObjRef::new(i as u32 + 3, 0));
field.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string().map(PdfString::to_text)).unwrap_or_default()
})
.collect();
assert!(values.iter().all(|v| !v.is_empty()), "no field is emptied");
assert_eq!(values[0], "d".repeat(1 << 20), "the first field goes back to its default");
assert_eq!(values[n - 1], "typed", "the last field, whose default wasn't read, keeps its value");
// A value the reading didn't decode, with no default, is cleared: it isn't known to be empty.
let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"v".repeat(1 << 20))];
objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R >>")));
let mut doc = document(&objs);
reset(&mut doc, None).unwrap();
let last = doc.get(ObjRef::new(n as u32 + 2, 0));
assert!(last.as_dict().and_then(|d| d.get(b"V")).is_none_or(|v| !matches!(v, Object::Ref(_))), "the last field's unread value is cleared");
}
/// Renaming a field that 300 calculations read through one shared Calculate action, whose script
/// is a 256 KiB stream, decodes and rewrites that action once, in place: every calculation reads
/// the new name, and nothing is decoded or written 300 times.
#[test]
fn a_rename_rewrites_a_shared_calculation_once() {
let n = 300;
let refs = |from: usize| -> String { (from..from + n).map(|i| format!("{i} 0 R ")).collect() };
let js = format!("AFSimple_Calculate(\"SUM\", new Array (\"a\"));{}", " ".repeat(256 << 10));
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [5 0 R {}] >>", refs(8)),
format!("<< /Fields [5 0 R {}] /DA (/Helv 0 Tf 0 g) >>", refs(8)),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Rect [50 700 250 720] /P 3 0 R >>".into(),
"<< /S /JavaScript /JS 7 0 R >>".into(),
raw_stream(&js),
];
objs.extend((0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (c{i}) /Rect [50 10 250 30] /P 3 0 R /AA << /C 6 0 R >> >>")));
let mut doc = document(&objs);
set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap();
let action = ObjRef::new(6, 0);
let renamed = (8..8 + n).all(|i| {
let field = doc.get(ObjRef::new(i as u32, 0));
field.as_dict().and_then(|d| d.get(b"AA")).and_then(|aa| aa.as_dict()?.get(b"C")?.as_ref()) == Some(action)
});
assert!(renamed, "every calculation still names the one shared action");
let js = doc.get(action).as_dict().and_then(|d| d.get(b"JS")?.as_string().map(PdfString::to_text)).unwrap_or_default();
assert!(js.contains("\"qty\"") && !js.contains("\"a\""), "the shared action reads the new name");
assert!(js.len() <= MAX_SCRIPT_BYTES, "{} bytes written once", js.len());
}
/// A page whose `/Contents` lists one 1 MiB stream ten times is read within the budget given:
/// pieces past it read as empty, so their rectangles aren't found.
#[test]
fn page_shapes_decode_a_repeated_content_stream_within_the_budget() {
let piece = format!("%{}\n10 10 20 20 re S\n", "x".repeat(1 << 20));
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", "4 0 R ".repeat(10)),
raw_stream(&piece),
]);
assert_eq!(crate::detect::page_shapes(&doc, 0).boxes.len(), 10, "one page's allowance reads them all");
let mut budget = DecodeBudget::new(4 << 20, 0);
let boxes = crate::detect::shapes_within(&doc, 0, &mut budget).boxes.len();
assert!((1..=4).contains(&boxes), "{boxes} rectangles read within 4 MiB");
assert_eq!(budget.remaining(), 0, "the budget is spent");
}
/// Field detection keeps at most 256 saved graphics states, and each `Q` still restores its own
/// `q` past that: a scale set 300 saves deep is gone once they are all restored.
#[test]
fn page_shapes_restore_saves_past_the_cap() {
let content = format!("{} 2 0 0 2 0 0 cm {} 10 10 20 20 re S", "q ".repeat(300), "Q ".repeat(300));
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(),
raw_stream(&content),
]);
assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[10.0, 10.0, 30.0, 30.0]]);
// A scale set past the cap and closed by its own `Q` leaves a matrix that can't be known until
// the last lost save closes: what is drawn in between isn't reported at the scaled place.
let content = format!("{} q 2 0 0 2 0 0 cm Q 10 10 20 20 re S {} 40 40 10 10 re S", "q ".repeat(257), "Q ".repeat(257));
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(),
raw_stream(&content),
]);
assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[40.0, 40.0, 50.0, 50.0]]);
}
/// The PDF/A declaration is read from metadata each filter stage of which decodes within
/// [`crate::appearances::MAX_XMP_BYTES`], as preflight decodes it: a field drawn with characters its
/// font lacks decodes no more than that a stage.
#[test]
fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() {
let declared = |xmp: String| declares_pdfa(&document(&["<< /Type /Catalog /Metadata 2 0 R >>".into(), raw_stream(&xmp)]));
let pdfa = |padding: usize| format!("<x:xmpmeta><pdfaid:part>2</pdfaid:part>{}</x:xmpmeta>", " ".repeat(padding));
assert!(declared(pdfa(1 << 20)));
assert!(!declared(pdfa(crate::appearances::MAX_XMP_BYTES)), "metadata past the cap declares nothing");
assert!(!declared("<x:xmpmeta><dc:title>pdfaid</dc:title></x:xmpmeta>".into()), "metadata without pdfaid:part declares nothing");
// 3.5 MiB stored as hex, then Flate: its stages decode 7 and 3.5 MiB, each within the cap.
let xmp = pdfa(7 << 19);
let hex: Vec<u8> = xmp.bytes().flat_map(|b| [b"0123456789ABCDEF"[usize::from(b >> 4)], b"0123456789ABCDEF"[usize::from(b & 15)]]).collect();
let mut stream = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &hex);
stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let mut doc = Document::new_empty();
let metadata = doc.add(Object::Stream(stream));
let catalog = doc.add(Object::Dict([(b"Metadata".to_vec(), Object::Ref(metadata))].into_iter().collect()));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
assert!(declares_pdfa(&doc), "metadata through a chain of filters declares PDF/A, as preflight decodes it");
}
/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share,
/// is read once: the first field to list it owns it.
#[test]
+24 -2
View File
@@ -470,7 +470,9 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
/// document that declares PDF/A, which forbids it: there the widget's mark
/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere.
pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> {
if declares_pdfa(doc) || need_appearances(doc) {
// The form's own flag first: once it is set, the metadata needn't be decoded again for each
// field drawn after.
if need_appearances(doc) || declares_pdfa(doc) {
return Ok(());
}
let Some(root) = doc.root() else { return Ok(()) };
@@ -625,6 +627,22 @@ pub fn clear_password_values(doc: &mut Document) -> Result<usize, FormError> {
Ok(targets.len())
}
/// Whether `field`'s `key` (`/V` or `/DV`, its own or inherited) is a stream with data, which
/// [`fields`] reads as empty once its value budget is spent.
fn unread_stream(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
let mut node = Some(field);
for _ in 0..64 {
let Some(r) = node else { break };
let obj = doc.get(r);
let Some(d) = obj.as_dict() else { break };
if let Some(value) = d.get(key) {
return matches!(&*doc.resolve(value), Object::Stream(s) if !s.raw.is_empty());
}
node = d.reference(b"Parent");
}
false
}
/// Acrobat's Clear form: every field (or the named ones) back to its default value.
pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, FormError> {
let all = fields(doc);
@@ -638,7 +656,11 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, Form
}
let mut changed = 0;
for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) {
if f.value == f.default {
// A default the reading didn't decode (its budget spent) is unknown, not empty: clearing
// the field to nothing would lose the value it should go back to. A value it didn't decode
// isn't known to be the default, so it is reset.
let unread = |key: &[u8], read: &[String]| read.is_empty() && unread_stream(doc, f.obj, key);
if unread(b"DV", &f.default) || (f.value == f.default && !unread(b"V", &f.value)) {
continue;
}
let v = match f.kind {