mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +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 * 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 of66415d737: - 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 ofc00e3d2fb: 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>
581 lines
24 KiB
Rust
581 lines
24 KiB
Rust
use super::tab_order::rank_tabs;
|
|
use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of};
|
|
|
|
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
|
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], 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))) {
|
|
return Some(af::ButtonAction::ImportIcon);
|
|
}
|
|
let pick = |dict: &Dict| -> Option<Object> {
|
|
dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned())))
|
|
};
|
|
let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?;
|
|
let a = doc.resolve(&action);
|
|
let a = a.as_dict()?;
|
|
let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v)));
|
|
Some(match a.name(b"S")? {
|
|
b"ResetForm" => {
|
|
let fields = a
|
|
.get(b"Fields")
|
|
.map(|f| doc.resolve(f))
|
|
.and_then(|f| f.as_array().cloned())
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|o| match o {
|
|
Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))),
|
|
other => text_of(&doc.resolve(other)),
|
|
})
|
|
.collect();
|
|
let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0;
|
|
af::ButtonAction::Reset { fields, exclude }
|
|
}
|
|
b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()),
|
|
b"URI" => af::ButtonAction::Uri(s(b"URI")?),
|
|
b"SubmitForm" => af::ButtonAction::Submit(
|
|
a.get(b"F")
|
|
.map(|f| doc.resolve(f))
|
|
.and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f)))
|
|
.unwrap_or_default(),
|
|
),
|
|
b"GoTo" => {
|
|
let dest = doc.resolve(a.get(b"D")?);
|
|
let target = dest.as_array()?.first()?.as_ref()?;
|
|
af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?)
|
|
}
|
|
b"Hide" => {
|
|
// /T: a field name, an annotation (or field) reference, or an array of them.
|
|
let target = |o: &Object| match o {
|
|
Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))),
|
|
other => text_of(other),
|
|
};
|
|
let fields = match a.get(b"T") {
|
|
Some(t) => match &*doc.resolve(t) {
|
|
Object::Array(items) => items.iter().filter_map(target).collect(),
|
|
_ => target(t).into_iter().collect(),
|
|
},
|
|
None => Vec::new(),
|
|
};
|
|
let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false)));
|
|
af::ButtonAction::ShowHide { fields, hide }
|
|
}
|
|
b"SetOCGState" => {
|
|
// /State: ON, OFF or Toggle, each followed by the groups it applies to.
|
|
let mut changes = Vec::new();
|
|
if let Some(state) = a.get(b"State") {
|
|
let mut op = None;
|
|
for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) {
|
|
match item {
|
|
Object::Name(n) => {
|
|
op = match n.as_slice() {
|
|
b"ON" => Some(af::LayerOp::On),
|
|
b"OFF" => Some(af::LayerOp::Off),
|
|
b"Toggle" => Some(af::LayerOp::Toggle),
|
|
_ => None,
|
|
}
|
|
}
|
|
Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
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, scripts)?),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to,
|
|
/// from the partial names (`/T`) up its `/Parent` chain.
|
|
fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
|
|
let mut parts = Vec::new();
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut cur = Some(r);
|
|
while let Some(c) = cur {
|
|
if seen.len() > 64 || !seen.insert(c) {
|
|
break;
|
|
}
|
|
let obj = doc.get(c);
|
|
let d = obj.as_dict()?;
|
|
if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) {
|
|
parts.push(t);
|
|
}
|
|
cur = d.get(b"Parent").and_then(|p| p.as_ref());
|
|
}
|
|
parts.reverse();
|
|
(!parts.is_empty()).then(|| parts.join("."))
|
|
}
|
|
|
|
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
|
|
/// in all, however many actions name the same stream.
|
|
pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
|
|
|
/// A budget for the script streams of one reading of the form.
|
|
pub(crate) 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.
|
|
pub(crate) 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) => 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], 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]))
|
|
.and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a))))
|
|
.and_then(|a| a.as_dict().cloned())
|
|
});
|
|
let mut out = af::Actions::default();
|
|
let Some(aa) = aa else { return out };
|
|
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,
|
|
None if !f.trim().is_empty() => {
|
|
out.unsupported.push("format");
|
|
out.scripts.format = Some(f);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
if let Some(k) = js(b"K")
|
|
&& out.format == af::Format::None
|
|
{
|
|
// A mask has only a keystroke script.
|
|
match af::parse_format(&k) {
|
|
Some(fm @ af::Format::Mask(_)) => out.format = fm,
|
|
Some(_) => {}
|
|
None if !k.trim().is_empty() => {
|
|
out.unsupported.push("keystroke");
|
|
out.scripts.keystroke = Some(k);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
if let Some(v) = js(b"V") {
|
|
match af::parse_validate(&v) {
|
|
Some(vv) => out.validate = vv,
|
|
None if !v.trim().is_empty() => {
|
|
out.unsupported.push("validate");
|
|
out.scripts.validate = Some(v);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
if let Some(c) = js(b"C") {
|
|
match af::parse_calculate(&c) {
|
|
Some(cc) => out.calculate = cc,
|
|
None if !c.trim().is_empty() => {
|
|
out.unsupported.push("calculate");
|
|
out.scripts.calculate = Some(c);
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 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) => 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(),
|
|
}
|
|
}
|
|
|
|
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
|
|
let o = doc.resolve(o?);
|
|
o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect()
|
|
}
|
|
|
|
/// Leaf page objects in order (a small walker; `organize` and `annot` have their own).
|
|
pub(crate) fn page_refs(doc: &Document) -> Vec<ObjRef> {
|
|
let Some(root) = doc.root() else { return Vec::new() };
|
|
let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() };
|
|
PageWalk::new(doc, pages).collect()
|
|
}
|
|
|
|
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
|
|
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
|
|
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
|
|
/// Cycles end at the seen check and at most a million nodes are visited.
|
|
pub(crate) struct PageWalk<'a> {
|
|
doc: &'a Document,
|
|
seen: std::collections::HashSet<ObjRef>,
|
|
pub(crate) pending: Vec<(std::sync::Arc<Object>, usize)>,
|
|
}
|
|
|
|
impl<'a> PageWalk<'a> {
|
|
pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self {
|
|
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
|
|
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
|
|
}
|
|
}
|
|
|
|
impl Iterator for PageWalk<'_> {
|
|
type Item = ObjRef;
|
|
|
|
fn next(&mut self) -> Option<ObjRef> {
|
|
while let Some((kids, next)) = self.pending.last_mut() {
|
|
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
|
|
self.pending.pop();
|
|
continue;
|
|
};
|
|
let node = kid.as_ref();
|
|
*next += 1;
|
|
let Some(node) = node else { continue };
|
|
if self.seen.len() >= 1_000_000 {
|
|
self.pending.clear();
|
|
return None;
|
|
}
|
|
if !self.seen.insert(node) {
|
|
continue;
|
|
}
|
|
let obj = self.doc.get(node);
|
|
let Some(d) = obj.as_dict() else { continue };
|
|
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
|
|
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
|
|
_ => return Some(node),
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The AcroForm dictionary.
|
|
pub fn acroform(doc: &Document) -> Option<Dict> {
|
|
let root = doc.root()?;
|
|
let cat = doc.get(root);
|
|
let af = cat.as_dict()?.get(b"AcroForm")?.clone();
|
|
doc.resolve(&af).as_dict().cloned()
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct Inherited {
|
|
name: String,
|
|
ft: Option<Vec<u8>>,
|
|
ff: Option<u32>,
|
|
v: Option<Object>,
|
|
dv: Option<Object>,
|
|
da: Option<String>,
|
|
q: Option<i64>,
|
|
max_len: Option<usize>,
|
|
}
|
|
|
|
/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the
|
|
/// page annotations (see [`adopt_page_fields`]).
|
|
pub fn fields(doc: &Document) -> Vec<Field> {
|
|
fields_with_adopted(doc).items
|
|
}
|
|
|
|
/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields`
|
|
/// list names none or only some of them, so the leniency that adopts them is worth recording.
|
|
pub fn adopted_page_fields(doc: &Document) -> usize {
|
|
fields_with_adopted(doc).adopted_page_fields
|
|
}
|
|
|
|
/// The terminal fields and page-only fields adopted while reading a form.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Fields {
|
|
/// Terminal fields in tree order, followed by adopted page-only fields.
|
|
pub items: Vec<Field>,
|
|
/// How many fields were reachable only through page annotations.
|
|
pub adopted_page_fields: usize,
|
|
}
|
|
|
|
/// Read all terminal fields and the page-only adoption count in one enumeration.
|
|
pub fn fields_with_adopted(doc: &Document) -> Fields {
|
|
let (items, adopted_page_fields) = enumerate(doc);
|
|
Fields { items, adopted_page_fields }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
/// Whole-form readings ([`enumerate`]) on this thread.
|
|
static FORM_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
/// How many times this thread has read the whole form.
|
|
#[cfg(test)]
|
|
pub(crate) fn form_reads() -> usize {
|
|
FORM_READS.with(std::cell::Cell::get)
|
|
}
|
|
|
|
pub(crate) fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
|
#[cfg(test)]
|
|
FORM_READS.with(|n| n.set(n.get() + 1));
|
|
let af = acroform(doc);
|
|
let mut page_of = std::collections::HashMap::new();
|
|
let mut annot_index = std::collections::HashMap::new();
|
|
let pages = page_refs(doc);
|
|
for (i, p) in pages.iter().enumerate() {
|
|
if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) {
|
|
for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() {
|
|
if let Some(r) = e.as_ref() {
|
|
page_of.entry(r).or_insert(i);
|
|
annot_index.entry(r).or_insert(k);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let base = match &af {
|
|
Some(af) => Inherited {
|
|
da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))),
|
|
q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()),
|
|
..Default::default()
|
|
},
|
|
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(),
|
|
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() {
|
|
walk(doc, r, &base, &page_of, &mut seen, &mut out, 0);
|
|
}
|
|
}
|
|
}
|
|
let listed = out.len();
|
|
adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out);
|
|
let adopted = out.len() - listed;
|
|
rank_tabs(doc, &pages, &annot_index, &mut out);
|
|
(out, adopted)
|
|
}
|
|
|
|
/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the
|
|
/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted
|
|
/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one
|
|
/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order.
|
|
fn adopt_page_fields(
|
|
doc: &Document,
|
|
pages: &[ObjRef],
|
|
base: &Inherited,
|
|
page_of: &std::collections::HashMap<ObjRef, usize>,
|
|
seen: &mut Seen,
|
|
out: &mut Vec<Field>,
|
|
) {
|
|
for p in pages {
|
|
let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
|
|
let annots = doc.resolve(&a);
|
|
let Some(annots) = annots.as_array() else { continue };
|
|
for e in annots {
|
|
let Some(r) = e.as_ref() else { continue };
|
|
if seen.fields.contains(&r) || seen.widgets.contains(&r) {
|
|
continue;
|
|
}
|
|
let obj = doc.get(r);
|
|
let Some(d) = obj.as_dict() else { continue };
|
|
if d.name(b"Subtype") != Some(b"Widget") {
|
|
continue;
|
|
}
|
|
if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") {
|
|
continue;
|
|
}
|
|
if let Some(root) = unlisted_root(doc, r, &seen.fields) {
|
|
walk(doc, root, base, page_of, seen, out, 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is
|
|
/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
|
|
fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet<ObjRef>) -> Option<ObjRef> {
|
|
let mut top = r;
|
|
let mut visited = std::collections::HashSet::from([r]);
|
|
loop {
|
|
let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent"));
|
|
let Some(p) = parent else {
|
|
return if seen.contains(&top) { None } else { Some(top) };
|
|
};
|
|
if !visited.insert(p) || visited.len() > 64 {
|
|
return None;
|
|
}
|
|
if seen.contains(&p) {
|
|
return None;
|
|
}
|
|
top = p;
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
|
|
fn walk(
|
|
doc: &Document,
|
|
r: ObjRef,
|
|
parent: &Inherited,
|
|
page_of: &std::collections::HashMap<ObjRef, usize>,
|
|
seen: &mut Seen,
|
|
out: &mut Vec<Field>,
|
|
depth: usize,
|
|
) {
|
|
if depth > 64 || !seen.fields.insert(r) {
|
|
return;
|
|
}
|
|
let obj = doc.get(r);
|
|
let Some(d) = obj.as_dict() else { return };
|
|
let mut inh = parent.clone();
|
|
if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) {
|
|
inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) };
|
|
}
|
|
if let Some(ft) = d.name(b"FT") {
|
|
inh.ft = Some(ft.to_vec());
|
|
}
|
|
if let Some(ff) = field_flags(doc, d) {
|
|
inh.ff = Some(ff);
|
|
}
|
|
for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] {
|
|
if let Some(v) = d.get(k) {
|
|
*slot = Some(v.clone());
|
|
}
|
|
}
|
|
if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) {
|
|
inh.da = Some(da);
|
|
}
|
|
if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) {
|
|
inh.q = Some(q);
|
|
}
|
|
if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) {
|
|
// Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen
|
|
// untouched in the object graph, but expose only positive limits to both UI and API.
|
|
// An explicit zero also clears an inherited limit rather than inheriting it again.
|
|
inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0);
|
|
}
|
|
let kids: Vec<ObjRef> =
|
|
d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
|
|
// Kids with /T are fields; kids without are this field's widgets.
|
|
let field_kids: Vec<ObjRef> = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect();
|
|
if !field_kids.is_empty() {
|
|
for k in field_kids {
|
|
walk(doc, k, &inh, page_of, seen, out, depth + 1);
|
|
}
|
|
return;
|
|
}
|
|
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
|
|
// field listing them then reads.
|
|
let ff = inh.ff.unwrap_or(0);
|
|
let kind = match inh.ft.as_deref() {
|
|
Some(b"Tx") => FieldKind::Text,
|
|
Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton,
|
|
Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio,
|
|
Some(b"Btn") => FieldKind::CheckBox,
|
|
Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo,
|
|
Some(b"Ch") => FieldKind::List,
|
|
Some(b"Sig") => FieldKind::Signature,
|
|
_ => return,
|
|
};
|
|
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
|
|
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
|
|
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 widgets = widget_refs
|
|
.into_iter()
|
|
.filter_map(|w| {
|
|
let wo = doc.get(w);
|
|
let wd = wo.as_dict()?;
|
|
let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]);
|
|
let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
|
let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) };
|
|
let on_state = wd
|
|
.get(b"AP")
|
|
.map(|ap| doc.resolve(ap))
|
|
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
|
|
.and_then(|n| doc.resolve(&n).as_dict().cloned())
|
|
.and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off"));
|
|
Some(Widget {
|
|
obj: w,
|
|
page: page_of.get(&w).copied(),
|
|
rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])],
|
|
on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)),
|
|
state: wd.name(b"AS").map(name_text),
|
|
tab: usize::MAX,
|
|
locked: annot_flags & 128 != 0,
|
|
hidden: annot_flags & (2 | 32) != 0,
|
|
read_only: (ff | own_ff) & flags::READ_ONLY != 0,
|
|
rotation: appearance::mk_rotation(doc, wd),
|
|
caption: (kind == FieldKind::PushButton)
|
|
.then(|| {
|
|
let mk = doc.resolve(wd.get(b"MK")?);
|
|
let value = doc.resolve(mk.as_dict()?.get(b"CA")?);
|
|
value.as_string().map(|s| s.to_text())
|
|
})
|
|
.flatten(),
|
|
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
|
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
|
|
})
|
|
})
|
|
.collect();
|
|
let options = d
|
|
.get(b"Opt")
|
|
.map(|o| doc.resolve(o))
|
|
.and_then(|o| o.as_array().cloned())
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.filter_map(|o| match &*doc.resolve(o) {
|
|
Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)),
|
|
other => text_of(other).map(|t| (t.clone(), t)),
|
|
})
|
|
.collect();
|
|
out.push(Field {
|
|
name: inh.name.clone(),
|
|
obj: r,
|
|
kind,
|
|
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,
|
|
da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()),
|
|
quadding: inh.q.unwrap_or(0),
|
|
tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))),
|
|
widgets,
|
|
actions,
|
|
button,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn field_flags(doc: &Document, dict: &Dict) -> Option<u32> {
|
|
dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok())
|
|
}
|