mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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
This commit is contained in:
1 parent
9bbcde4699
commit
71950c6443
6 files changed
+127
-15
No files matched your search
@@ -1,6 +1,6 @@
|
||||
use super::read::{acroform, fields};
|
||||
use super::values::merged_appearance;
|
||||
use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes};
|
||||
use super::{DecodeBudget, Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes};
|
||||
|
||||
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
|
||||
pub fn need_appearances(doc: &Document) -> bool {
|
||||
@@ -212,13 +212,18 @@ impl FieldAppearanceCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`).
|
||||
/// The most of the XMP metadata stream decoded to find a PDF/A declaration, as preflight's
|
||||
/// Standards panel reads it: 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 (`pdfaid:part`). Metadata that
|
||||
/// 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")),
|
||||
Object::Stream(s) => DecodeBudget::new(MAX_XMP_BYTES, 0).decode(s).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,10 +583,13 @@ 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.
|
||||
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();
|
||||
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.
|
||||
@@ -600,7 +603,7 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -125,7 +125,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,7 +147,7 @@ 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3575,6 +3575,88 @@ 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");
|
||||
}
|
||||
|
||||
/// Renaming a field that 300 calculations read through one 256 KiB script stream decodes the
|
||||
/// stream within one script budget: the calculations past it are left as they are, not 75 MiB
|
||||
/// decoded and written back.
|
||||
#[test]
|
||||
fn a_rename_decodes_repeated_calculation_streams_within_one_budget() {
|
||||
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 rewritten: Vec<usize> = (8..8 + n)
|
||||
.filter_map(|i| {
|
||||
let field = doc.get(ObjRef::new(i as u32, 0));
|
||||
let c = field.as_dict()?.get(b"AA")?.as_dict()?.get(b"C")?.as_dict()?.clone();
|
||||
let js = c.get(b"JS")?.as_string()?.to_text();
|
||||
js.contains("\"qty\"").then_some(js.len())
|
||||
})
|
||||
.collect();
|
||||
assert!(!rewritten.is_empty(), "the first calculations are renamed");
|
||||
let total: usize = rewritten.iter().sum();
|
||||
assert!(total <= MAX_SCRIPT_BYTES, "{} calculations, {total} bytes, renamed", rewritten.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!(budget.exhausted());
|
||||
}
|
||||
|
||||
/// The PDF/A declaration is read from at most [`crate::appearances::MAX_XMP_BYTES`] of metadata, as
|
||||
/// preflight reads it: a field drawn with characters its font lacks decodes no more than that.
|
||||
#[test]
|
||||
fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() {
|
||||
let declared = |padding: usize| {
|
||||
let xmp = format!("<x:xmpmeta><pdfaid:part>2</pdfaid:part>{}</x:xmpmeta>", " ".repeat(padding));
|
||||
declares_pdfa(&document(&["<< /Type /Catalog /Metadata 2 0 R >>".into(), raw_stream(&xmp)]))
|
||||
};
|
||||
assert!(declared(1 << 20));
|
||||
assert!(!declared(crate::appearances::MAX_XMP_BYTES), "metadata past the cap declares nothing");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -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(()) };
|
||||
|
||||
Reference in new issue
Block a user