use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; 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 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, which each would otherwise copy. Past it, a bookmark or link to a web address lists /// as a URI action without its address, an attachment 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 = if c.len() == 4 { c.iter() .map(|o| match o { Object::Reference(_) => None, o => Some(o.as_float().unwrap_or(0.0)), }) .collect::>()? } else { c.iter().map(|o| o.as_float().ok()).collect::>()? }; 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::(), ]) .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) { 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 = 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, text_budget) { 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::().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 = 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: self.text(d, b"State"), 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. fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { 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 { self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? }; Some(Link { page, index, rect, 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 { 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 { 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; 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 { 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 { 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 { 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 { 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, Option)>, out: &mut Vec, 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 kids = match d.get(b"Kids").map(|k| self.resolve(k)) { Ok(Object::Array(k)) => k.as_slice(), _ => &[], }; let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())); let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect(); if !field_kids.is_empty() { let id = if let Object::Reference(id) = o { Some(*id) } else { None }; walk.path.extend(id); for k in field_kids { self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1); } if id.is_some() { walk.path.pop(); } 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::>() .join(", "), ), Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), _ => None, }; // The widget is either this dict (merged field/widget) or its first kid. 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 = if merged { Some(d) } else { 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 { 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 { 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 { 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 { 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 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) -> Option { 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, pub(super) roots: HashSet, path: Vec, } /// 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 = 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], } }