Files
pdfcraft/crates/forms/src/read.rs
T
aiongg 4362d705e6 refactor(forms): split lib.rs into read, tab_order, values, appearances
New modules: read.rs, tab_order.rs, values.rs, appearances.rs.
2026-10-26 22:18:50 +00:00

569 lines
23 KiB
Rust

use super::tab_order::rank_tabs;
use super::*;
/// 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
}
fn values_of(doc: &Document, o: Option<&Object>) -> 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(),
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() };
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, and
/// the script budget their actions 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,
}
/// 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()),
default: values_of(doc, inh.dv.as_ref()),
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())
}