Files
pdfcraft/crates/render/src/inspect/annotations.rs
T
mtlcl410 ad0fab3339 Merge remote-tracking branch 'upstream/main' into lane4-4474b
# Conflicts:
#	crates/automation/src/tools.rs
#	crates/automation/tests/it/automation.rs
#	crates/ui-egui/tests/it/document_links.rs
2026-11-09 18:52:46 +08:00

705 lines
35 KiB
Rust

use super::core::{limited_pdf_string, pretty_date, spend};
use super::model::{
Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies, state_or_default,
};
use super::navigation::text_string;
use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId};
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
/// File specifications retained from a remote-Go-To action are deliberately small.
pub(super) const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest
/// are not listed: real comments stay far below, many small ones sharing one long string do not.
pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20;
/// Web-address text and `/QuadPoints` geometry the bookmark and link listings, and name,
/// description and embedded-file key text the attachment listing, keep together. Many bookmarks,
/// links or attachments can share one long string or array, which each would otherwise copy. Past
/// it, a bookmark or link to a web address lists as a URI action without its address (or a link
/// keeps a relative address unresolved against the document's base), a link is active over its
/// whole `/Rect`, an attachment is listed under its generic name without a description, and an
/// embedded file whose key does not fit is not listed.
pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
/// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color`
/// and `drawn_color`): one entry is grey, three RGB, four CMYK; among four entries a non-number is
/// 0 but a reference is unreadable, otherwise every entry must be a number. `None` for anything
/// else, an empty array or a value out of range; each component is clamped to 0..=1.
fn drawn_color(c: &[Object]) -> Option<[f32; 3]> {
let v: Vec<f32> = if c.len() == 4 {
c.iter()
.map(|o| match o {
Object::Reference(_) => None,
o => Some(o.as_float().unwrap_or(0.0)),
})
.collect::<Option<_>>()?
} else {
c.iter().map(|o| o.as_float().ok()).collect::<Option<_>>()?
};
let rgb = match v.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
_ => return None,
};
// pdfcraft-annot's `finite`: finite and below 1e7 in magnitude.
rgb.iter().all(|x| x.is_finite() && x.abs() < 1e7).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
/// The bytes a listed annotation keeps.
pub(super) fn retained(a: &Annotation) -> usize {
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
text.into_iter()
.map(|t| t.as_ref().map_or(0, String::len))
.chain([
a.subtype.len(),
std::mem::size_of_val(a.quads.as_slice()),
std::mem::size_of_val(a.points.as_slice()),
std::mem::size_of::<Annotation>(),
])
.fold(0, usize::saturating_add)
}
impl<'a> Inspector<'a> {
/// Whether an annotation is a Fill & Sign mark. Keep in step with
/// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses.
fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool {
let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
if yes(b"PCFillSign") {
return true;
}
match subtype {
"FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"),
"Stamp" => match self.name(d, b"Name").as_deref() {
Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true,
Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"),
_ => false,
},
"Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"),
_ => false,
}
}
// ── annotations ─────────────────────────────────────────────────────────────────────────
/// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`].
pub(super) fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) {
// Indirect `/QuadPoints` arrays whose quadrilaterals did not fit what was left of the budget:
// the budget only shrinks, so they are not read again (review on #4474).
let mut unfit_quads: HashSet<ObjectId> = HashSet::new();
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
pages.sort_unstable_by_key(|&(_, page)| page);
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
// a reference resolves to, since an indirect object may itself hold a reference. Where each
// annotation is listed is collected before any is read, so a reply's `/IRT` finds its
// parent by reference even when the parent comes later.
let mut seen_arrays = HashSet::new();
let mut slots: HashMap<ObjectId, (usize, usize)> = HashMap::new();
let mut entries = Vec::new();
for (id, page) in pages {
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
if let Ok(annots) = page_dict.get(b"Annots")
&& let Some(r) = self.resolved_id(annots)
&& !seen_arrays.insert(r)
{
continue;
}
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for (index, a) in annots.iter().enumerate() {
if let Some(r) = self.resolved_id(a) {
let Entry::Vacant(slot) = slots.entry(r) else { continue };
slot.insert((page, index));
}
entries.push((page, index, a));
}
}
let mut kept = 0usize;
// Once the comment budget is spent, comments are no longer built, but links and attachments
// are still listed.
let mut comments_full = false;
for (page, index, a) in entries {
let Some(d) = self.dict(a) else { continue };
// An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166):
// a font or other dictionary a page lists among its annotations isn't one, and neither is
// one whose `/Type` isn't a name at all. A null or dangling `/Type` counts as absent
// (§7.3.9: a null value is the same as leaving the entry out).
if let Ok(t) = d.get(b"Type").map(|t| self.resolve(t))
&& !matches!(t, Object::Null | Object::Reference(_))
&& t.as_name().ok() != Some(b"Annot".as_slice())
{
continue;
}
let Some(subtype) = self.name(d, b"Subtype") else { continue };
if subtype == "FileAttachment"
&& let Ok(fs) = d.get(b"FS")
{
let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget);
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
}
if subtype == "Link" {
if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
info.links.push(link);
}
continue;
}
// Not comments: form widgets, pop-ups and non-markup annotations such as the
// Screen annotation that drives a LaTeX `animate` player (keep in step with
// `pdfcraft_annot::is_comment_subtype`).
if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") {
continue;
}
if comments_full {
continue;
}
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
let c = match d.get(b"C").map(|o| self.resolve(o)) {
Ok(Object::Array(c)) => Some(c),
_ => None,
};
// Three numbers, some given by reference, with a non-number read as 0.
let rgb = c.filter(|c| c.len() == 3).map(|c| {
let f = |i: usize| c.get(i).map_or(0.0, |o| self.resolve(o).as_float().unwrap_or(0.0));
[f(0), f(1), f(2)]
});
// Other comments list the stroke colour as pdfcraft-annot draws it and lists it
// (`summaries`): grey, CMYK, a non-number among four entries read as 0.
let mut color = if subtype == "FreeText" { rgb } else { c.and_then(|c| drawn_color(c)).or(rgb) };
// A text box's /C is its background; its text colour is in /DA.
if subtype == "FreeText"
&& let Some(da) = self.text(d, b"DA")
{
let t: Vec<&str> = da.split_whitespace().collect();
if let Some(i) = t.iter().position(|x| *x == "rg")
&& i >= 3
{
let f = |k: usize| t[k].parse::<f32>().unwrap_or(0.0);
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
}
}
// Only usable quads, by the rule redaction apply reads them with (keep in step with
// `pdfcraft_annot::summaries`): a malformed one is not drawn as zeros.
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
Ok(Object::Array(q)) => {
let entries = q.iter().map(|o| self.resolve(o).as_float().ok().map(f64::from));
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|quad| quad.map(|v| v as f32)).collect()
}
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied());
let fill_sign = self.is_fill_sign(d, &subtype);
// Keep in step with `pdfcraft_annot::summaries`.
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
let points = match d.get(key).map(|o| self.resolve(o)) {
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
}
_ => Vec::new(),
};
let annotation = Annotation {
page,
subtype,
author: self.text(d, b"T"),
// Acrobat ends lines with CR, which the interface would draw as nothing (keep
// in step with `pdfcraft_annot::summaries`).
contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
name: self.text(d, b"NM"),
in_reply_to,
parent,
grouped: self.name(d, b"RT").as_deref() == Some("Group"),
rect,
color,
index,
state: state_or_default(self.text(d, b"State"), self.text(d, b"StateModel").as_deref()),
quads,
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
fill_sign,
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
points,
};
kept = kept.saturating_add(retained(&annotation));
if kept > MAX_ANNOTATION_BYTES {
comments_full = true;
continue;
}
info.annotations.push(annotation);
}
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
link_replies(&mut info.annotations);
}
/// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest`
/// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no
/// rectangle, or a destination or action that leads nowhere. `base` is the catalog's
/// `/URI /Base`, which a relative URI action is resolved against. `unfit_quads` holds the
/// indirect `/QuadPoints` arrays already found too large for the budget.
fn link(
&self,
page: usize,
index: usize,
d: &Dictionary,
base: Option<&str>,
text_budget: &mut usize,
unfit_quads: &mut HashSet<ObjectId>,
) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
let (page, view) = self.dest(dest, 0)?;
LinkTarget::Page(page, view)
} else {
match self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? {
LinkTarget::Uri(uri) => match crate::resolve_uri(base, &uri) {
// The raw address is spent already. What the base adds is spent too, or the
// link keeps the raw address.
Some(resolved) if resolved.len().saturating_sub(uri.len()) <= *text_budget => {
*text_budget = text_budget.saturating_sub(resolved.len().saturating_sub(uri.len()));
LinkTarget::Uri(resolved)
}
_ => LinkTarget::Uri(uri),
},
other => other,
}
};
// Not read at all once no quadrilateral would fit what is left of the budget, or when this
// shared array already did not fit: many links can share one long array, and reading it
// again for each would cost its length every time (review on #4474).
let shared = d.get(b"QuadPoints").ok().and_then(|o| o.as_reference().ok());
let readable = *text_budget >= std::mem::size_of::<[f32; 8]>() && shared.is_none_or(|id| !unfit_quads.contains(&id));
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
// Read no further than one value past the cap: a longer array isn't used.
Ok(Object::Array(q)) if readable && q.len() <= crate::MAX_LINK_QUADS.saturating_mul(8) => {
#[cfg(test)]
QUAD_ARRAYS_READ.with(|n| n.set(n.get() + 1));
let values: Vec<Option<f32>> = q.iter().map(|o| self.resolve(o).as_float().ok()).collect();
let quads = crate::link_quads(&values, rect);
// Many links can share one long array, which each would otherwise copy. Past the
// budget a link is active over its whole `/Rect`.
let bytes = std::mem::size_of_val(quads.as_slice());
if bytes <= *text_budget {
*text_budget = text_budget.saturating_sub(bytes);
quads
} else {
if let Some(id) = shared {
unfit_quads.insert(id);
}
Vec::new()
}
}
_ => Vec::new(),
};
Some(Link { page, index, rect, quads, target })
}
/// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in
/// this document; a remote go-to's names a page or destination of another file (ISO 32000-2
/// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here.
/// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]).
pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option<LinkTarget> {
Some(match self.name(a, b"S").as_deref()? {
"GoTo" => {
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
LinkTarget::Page(page, view)
}
"URI" => {
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) {
Some(uri) => LinkTarget::Uri(uri),
None => LinkTarget::Other("URI".into()),
}
}
"SetOCGState" => self.layer_state(a),
"GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
"Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())),
other => LinkTarget::Other(other.to_string()),
})
}
fn remote_go_to(&self, action: &Dictionary) -> Option<LinkTarget> {
let file = self.action_file(action)?;
let destination = match action.get(b"D") {
Err(_) => RemoteDestination::Default,
Ok(o) => match self.resolve(o) {
Object::Array(items) => {
let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?;
let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok());
let operands: [Option<f32>; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok()));
RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) }
}
Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
let name = String::from_utf8_lossy(name).into_owned();
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
return None;
}
RemoteDestination::Named(name)
}
Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
let name = text_string(bytes);
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
return None;
}
RemoteDestination::Named(name)
}
_ => return None,
},
};
Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) })
}
/// The non-empty local file an action's `/F` names (a string or a file specification), shared
/// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file.
fn action_file(&self, action: &Dictionary) -> Option<String> {
if self.name(action, b"FS").as_deref() == Some("URL") {
return None;
}
let file = match self.resolve(action.get(b"F").ok()?) {
Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?,
Object::Dictionary(spec) => {
if self.name(spec, b"FS").as_deref() == Some("URL") {
return None;
}
spec.get(b"UF")
.ok()
.and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES))
.or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))?
}
_ => return None,
};
(!file.is_empty()).then_some(file)
}
fn new_window(&self, action: &Dictionary) -> Option<bool> {
match action.get(b"NewWindow").map(|o| self.resolve(o)) {
Ok(Object::Boolean(value)) => Some(*value),
_ => None,
}
}
/// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as
/// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything
/// else, or with the deprecated `/Win` launch parameters (an application, an operation, its
/// arguments), is not followed: a document must not start programs. `\` separators, which
/// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with
/// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written
/// for GoToR, or junk) is ignored rather than checked as a destination.
fn launch_pdf(&self, action: &Dictionary) -> Option<LinkTarget> {
if action.has(b"Win") {
return None;
}
let file = self.action_file(action)?;
let new_window = self.new_window(action);
let file = if file.contains('/') { file } else { file.replace('\\', "/") };
let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf"));
pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window })
}
fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option<String> {
match self.resolve(o) {
Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes),
_ => None,
}
}
/// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups
/// it applies to; `/PreserveRB` is true unless it is `false`.
fn layer_state(&self, a: &Dictionary) -> LinkTarget {
let mut changes = Vec::new();
if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) {
let mut op = None;
for item in items.iter().take(MAX_LAYER_STATE) {
match item {
Object::Name(n) => {
op = match n.as_slice() {
b"ON" => Some(LayerOp::On),
b"OFF" => Some(LayerOp::Off),
b"Toggle" => Some(LayerOp::Toggle),
_ => None,
}
}
Object::Reference(id) => changes.extend(op.map(|op| (op, *id))),
_ => {}
}
}
}
let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false)));
LinkTarget::SetLayers { changes, preserve_rb }
}
// ── form fields ─────────────────────────────────────────────────────────────────────────
pub(super) fn field(
&self,
o: &'a Object,
parent: Option<(&str, Option<String>, Option<i64>)>,
out: &mut Vec<Field>,
walk: &mut FieldWalk,
depth: u32,
) {
if depth > 32 {
return;
}
if let Object::Reference(id) = o
&& !walk.seen.insert(*id)
{
return;
}
let Some(d) = self.dict(o) else { return };
let (parent_name, parent_ft, parent_ff) = parent.unwrap_or(("", None, None));
let partial = self.text(d, b"T");
let full = match (&partial, parent_name.is_empty()) {
(Some(t), true) => t.clone(),
(Some(t), false) => format!("{parent_name}.{t}"),
(None, _) => parent_name.to_string(),
};
let ft = self.name(d, b"FT").or(parent_ft);
let ff = d.get(b"Ff").ok().and_then(|x| x.as_i64().ok()).or(parent_ff);
// Non-terminal: has kids that are fields (they carry /T). Widgets-only kids are terminal.
let mut kids: Vec<Object> = match d.get(b"Kids").map(|k| self.resolve(k)) {
Ok(Object::Array(k)) => k.clone(),
_ => Vec::new(),
};
let (field_kids, widget_kids): (Vec<Object>, Vec<Object>) =
kids.iter().cloned().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok()));
let field_kids: Vec<_> = field_kids.iter().filter(|k| !self.not_a_child(walk, o, k)).collect();
if !field_kids.is_empty() {
let here = match o {
Object::Reference(id) => Some(*id),
_ => None,
};
walk.path.extend(here);
for k in field_kids {
// A fully qualified name is built up the field's own `/Parent` chain (ISO 32000-2
// §12.7.4.2): a kid that doesn't name this field as its `/Parent` is named from its
// own chain, as the form reader names it.
let parent = match self.dict(k).map(|kd| kd.get(b"Parent")) {
Some(Ok(Object::Reference(p))) => Some(*p),
_ => None,
};
if parent == here {
self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1);
} else {
let own = parent.map(|p| self.qualified_name(p)).unwrap_or_default();
self.field(k, Some((&own, ft.clone(), ff)), out, walk, depth + 1);
}
}
if here.is_some() {
walk.path.pop();
}
// "A field's children in the hierarchy may also include widget annotations" (ISO
// 32000-2 §12.7.4): widget annotations listed beside named kids are still this
// field's widgets, listed as a field of this name, as the form reader reads them.
kids.retain(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_err() && self.name(kd, b"Subtype").as_deref() == Some("Widget")));
if kids.is_empty() {
return;
}
}
let flags = ff.unwrap_or(0);
let kind = match ft.as_deref() {
Some("Tx") => FieldKind::Text,
Some("Btn") if flags & (1 << 16) != 0 => FieldKind::PushButton,
Some("Btn") if flags & (1 << 15) != 0 => FieldKind::Radio,
Some("Btn") => FieldKind::CheckBox,
Some("Ch") if flags & (1 << 17) != 0 => FieldKind::Combo,
Some("Ch") => FieldKind::List,
Some("Sig") => FieldKind::Signature,
_ => FieldKind::Unknown,
};
let value = match d.get(b"V").map(|v| self.resolve(v)) {
Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()),
Ok(Object::String(bytes, _)) => Some(text_string(bytes)),
Ok(Object::Array(a)) => Some(
a.iter()
.filter_map(|x| match self.resolve(x) {
Object::String(bytes, _) => Some(text_string(bytes)),
_ => None,
})
.collect::<Vec<_>>()
.join(", "),
),
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
_ => None,
};
// The widget is this dict (merged field/widget) or its first kid. A merged dict that also
// lists widget kids has them as its widgets (ISO 32000-2 §12.7.4), as the form reader has
// it. With no widget among its kids, it is the first page widget naming this field as its
// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and
// PDFium read it.
let merged = d.get(b"Rect").is_ok();
let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) };
let widget_kid = widget_kids.iter().filter_map(|k| self.dict(k)).find(|kd| self.name(kd, b"Subtype").as_deref() == Some("Widget"));
let widget = match widget_kid {
Some(w) => Some(w),
None if merged => Some(d),
None => widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok())),
};
let page = widget
.and_then(|w| w.get(b"P").ok())
.and_then(|p| self.page_of(p))
.or_else(|| self.find_widget_page(o, &kids))
.or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied()));
let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok());
let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r)));
out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect });
}
/// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself
/// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't
/// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224),
/// and a child names its parent (§12.7.4.1, Table 226).
fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool {
let Object::Reference(k) = k else { return false };
let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
_ => None,
});
let o = if let Object::Reference(o) = o { Some(*o) } else { None };
o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o))
}
/// The first page widget that names field `o` as its `/Parent` and is not a field of its own
/// (no `/T`), in page-annotation order.
fn widget_naming(&self, o: &Object) -> Option<ObjectId> {
let Object::Reference(id) = o else { return None };
self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied()
}
/// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order.
fn parent_widgets(&self) -> HashMap<ObjectId, ObjectId> {
let mut first = HashMap::new();
for &pid in &self.page_ids {
let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue };
let Ok(annots) = self.resolve(annots).as_array() else { continue };
for a in annots {
let Object::Reference(id) = a else { continue };
let Ok(w) = self.doc.get_dictionary(*id) else { continue };
if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() {
continue;
}
if let Ok(Object::Reference(parent)) = w.get(b"Parent") {
first.entry(*parent).or_insert(*id);
}
}
}
first
}
/// The first page whose `/Annots` lists the field or one of its kids.
fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option<usize> {
let pages = self.annotation_pages.get_or_init(|| self.annotation_pages());
std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min()
}
/// Each annotation listed in a page's `/Annots`, with the first page that lists it.
fn annotation_pages(&self) -> HashMap<ObjectId, usize> {
let mut pages = HashMap::new();
for (idx, &pid) in self.page_ids.iter().enumerate() {
let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue };
let Ok(annots) = self.resolve(annots).as_array() else { continue };
for a in annots {
if let Object::Reference(id) = a {
pages.entry(*id).or_insert(idx);
}
}
}
pages
}
/// The fully qualified name of the field `id`: the partial names (`/T`) up its `/Parent`
/// chain, joined with periods; empty when none has one or the chain reaches an object that
/// isn't a dictionary, as the form reader has it. A `/Parent` loop ends the walk.
fn qualified_name(&self, id: ObjectId) -> String {
let mut parts = Vec::new();
let mut visited = HashSet::new();
let mut cur = Some(id);
while let Some(c) = cur {
if visited.len() > 64 || !visited.insert(c) {
break;
}
let o = Object::Reference(c);
let Some(d) = self.dict(&o) else { return String::new() };
parts.extend(self.text(d, b"T"));
cur = match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
_ => None,
};
}
parts.reverse();
parts.join(".")
}
/// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
let mut top = id;
let mut visited = HashSet::from([id]);
loop {
let o = Object::Reference(top);
let d = self.dict(&o)?;
// A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as
// `null`, the same as no parent (ISO 32000-2 §7.3.9).
let parent = match d.get(b"Parent") {
Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p),
_ => None,
};
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 keeps: the fields read, the root fields `/Fields` lists,
/// and the fields whose `/Kids` are being read, outermost first.
#[derive(Default)]
pub(super) struct FieldWalk {
pub(super) seen: HashSet<ObjectId>,
pub(super) roots: HashSet<ObjectId>,
path: Vec<ObjectId>,
}
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
/// `u32` counts as no flags.
fn draw_flags(o: &Object) -> u32 {
let f = match o {
Object::Integer(i) => *i,
Object::Real(r) => *r as i64,
_ => 0,
};
u32::try_from(f).unwrap_or(0)
}
fn rect4(o: &Object) -> [f32; 4] {
match o {
Object::Array(a) if a.len() == 4 => {
let v: Vec<f32> = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect();
[v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]
}
_ => [0.0; 4],
}
}
#[cfg(test)]
thread_local! {
/// `/QuadPoints` arrays whose values were read.
pub(super) static QUAD_ARRAYS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}