Files
pdfcraft/crates/forms/src/appearances.rs
T
c0cd40f2e1 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>
2026-11-04 13:51:40 +01:00

249 lines
12 KiB
Rust

use super::read::{acroform, fields};
use super::values::merged_appearance;
use super::{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 {
acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))
}
/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for
/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from
/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the
/// writer left the appearances to the viewer), and otherwise each widget without a normal
/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons
/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border
/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own
/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is
/// not changed: saving writes the file as it was.
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
}
/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are
/// the same objects as then (see [`FieldAppearanceCache`]).
pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option<Document> {
let all = need_appearances(doc);
let mut copy: Option<Document> = None;
for f in fields(doc) {
for w in &f.widgets {
let widget = doc.get(w.obj);
let normal = widget
.as_dict()
.and_then(|d| d.get(b"AP"))
.and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n))));
// A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`,
// else the value when it names this widget's on state (any check box value when the
// widget names none), else Off. An Off state may go without one (it shows nothing);
// an on state needs one.
let state = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => {
let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v);
let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine);
Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into()))
}
_ => None,
};
// With /NeedAppearances a check box or radio button that describes its look in /MK is
// drawn from it (Table 192), its own appearance or not; one without /MK keeps its own.
let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some());
let missing = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
FieldKind::PushButton => normal.is_none(),
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
_ if all && described() => true,
(None, _) => true,
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
_ => false,
},
FieldKind::Signature => false,
};
if !missing {
continue;
}
let inputs = appearance_inputs(doc, w);
let stream = match cache.get(w.obj, &inputs) {
Some(stream) => stream,
None => {
let stream = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")),
FieldKind::PushButton => appearance::push_button_shown(doc, &f, w),
_ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0,
};
cache.insert(w.obj, inputs, &stream);
stream
}
};
let target = copy.get_or_insert_with(|| doc.clone());
let ap = target.add(Object::Stream(stream));
let mut fresh = Dict::new();
match &state {
// The drawn look under its state's name, and that state as the widget's own.
Some(s) => {
let mut states = Dict::new();
states.set(name_bytes(s), Object::Ref(ap));
fresh.set(b"N".to_vec(), Object::Dict(states));
}
None => fresh.set(b"N".to_vec(), Object::Ref(ap)),
}
let apd = merged_appearance(target, w.obj, &fresh);
// Fails only when the widget isn't a dictionary; that widget then keeps its own look.
target
.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
if let Some(s) = &state {
d.set(b"AS".to_vec(), Object::Name(name_bytes(s)));
}
})
.ok();
}
}
copy
}
/// The objects a widget's drawn appearance is built from, as the document holds them: the widget
/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the
/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to
/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`.
fn appearance_inputs(doc: &Document, w: &Widget) -> Vec<std::sync::Arc<Object>> {
// The objects a dictionary's entries refer to. The tree links and the drawn appearance itself
// are not inputs, and `/Kids` and `/Fields` can be long.
let referred = |d: &Dict, inputs: &mut Vec<std::sync::Arc<Object>>| {
for (k, v) in d.iter() {
if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP")
&& let Some(r) = v.as_ref()
{
inputs.push(doc.get(r));
}
}
};
let mut inputs = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut node = Some(w.obj);
// The /Parent chain; the seen-set and the cap end a cycle or an absurd depth.
while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) {
let obj = doc.get(r);
if let Some(d) = obj.as_dict() {
node = d.reference(b"Parent");
referred(d, &mut inputs);
} else {
node = None;
}
inputs.push(obj);
}
let Some(root) = doc.root() else { return inputs };
let catalog = doc.get(root);
let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) {
Some(r) => doc.get(r),
None => catalog,
};
if let Some(f) = form.as_dict() {
referred(f, &mut inputs);
// The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to
// it; a direct one is part of the object that holds it. `held` only finds them: a copy
// of a direct object is a new one each time, never an input.
let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r));
if let Some(dr) = f.get(b"DR").map(held)
&& let Some(dr) = dr.as_dict()
{
referred(dr, &mut inputs);
if let Some(font) = dr.get(b"Font").map(held)
&& let Some(fonts) = font.as_dict()
{
referred(fonts, &mut inputs);
}
}
}
inputs.push(form);
inputs
}
/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh
/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the
/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An
/// entry holds the input objects it was drawn from and is used only while each is still the same
/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a
/// miss, never a stale hit.
#[derive(Clone, Debug, Default)]
pub struct FieldAppearanceCache {
entries: std::collections::HashMap<ObjRef, (Vec<std::sync::Arc<Object>>, pdfcraft_cos::Stream)>,
bytes: usize,
hits: u64,
builds: u64,
}
impl FieldAppearanceCache {
/// Cached appearance data restarts past this instead of growing without bound.
const MAX_BYTES: usize = 64 << 20;
/// Forget every entry.
pub fn clear(&mut self) {
*self = Self::default();
}
fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc<Object>]) -> Option<pdfcraft_cos::Stream> {
let (from, stream) = self.entries.get(&w)?;
if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) {
return None;
}
self.hits += 1;
Some(stream.clone())
}
fn insert(&mut self, w: ObjRef, inputs: Vec<std::sync::Arc<Object>>, stream: &pdfcraft_cos::Stream) {
self.builds += 1;
if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES {
self.clear();
}
self.bytes = self.bytes.saturating_add(stream.raw.len());
if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) {
self.bytes = self.bytes.saturating_sub(old.raw.len());
}
}
/// Cache hits so far (reused appearances); for tests.
pub fn hits(&self) -> u64 {
self.hits
}
/// Appearances drawn so far; for tests.
pub fn builds(&self) -> u64 {
self.builds
}
}
/// 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 {
// 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,
}
}
/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font
/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per
/// field.
pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> {
fields(doc)
.into_iter()
.filter_map(|f| {
let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?;
Some((f.name, c))
})
.collect()
}