From faf57b47557dfb933029acaa8380c1c52255e647 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:34:44 +0000 Subject: [PATCH] refactor(render): split inspect.rs into inspect/ New modules: core, annotations, resources, navigation, model. The small test modules (version_tests, open_action_javascript_tests, substitution_tests, open_dest_tests) moved with the code they test. --- crates/render/src/inspect.rs | 2433 +--------------------- crates/render/src/inspect/annotations.rs | 474 +++++ crates/render/src/inspect/core.rs | 417 ++++ crates/render/src/inspect/model.rs | 539 +++++ crates/render/src/inspect/navigation.rs | 550 +++++ crates/render/src/inspect/resources.rs | 468 +++++ 6 files changed, 2468 insertions(+), 2413 deletions(-) create mode 100644 crates/render/src/inspect/annotations.rs create mode 100644 crates/render/src/inspect/core.rs create mode 100644 crates/render/src/inspect/model.rs create mode 100644 crates/render/src/inspect/navigation.rs create mode 100644 crates/render/src/inspect/resources.rs diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index daa6d1d1a7..0cf287b3f1 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -23,60 +23,27 @@ use pdfcraft_cos::page_labels::{ use crate::OpenError; use crate::structure::{LazyStructure, Structure}; -/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is -/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and -/// xref streams are far below this. -const LOAD_STREAM_LIMIT: usize = 256 << 20; +mod annotations; +mod core; +mod model; +mod navigation; +mod resources; -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to -/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. -const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; - -/// File specifications retained from a remote-Go-To action are deliberately small. -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. -const MAX_ANNOTATION_BYTES: usize = 64 << 20; - -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a 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. -const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; - -/// The bytes a listed annotation keeps. -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) -} - -/// At most this many layers are read from all of `/RBGroups` together. -const MAX_LAYER_GROUP_ENTRIES: usize = 4096; - -/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document -/// reads only this window; the rest are listed by page through `pdfcraft_organize`. -const OUTLINE_WINDOW: usize = 10_000; - -/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. -const OUTLINE_DEPTH: usize = 32; - -fn load_options(password: Option<&str>) -> LoadOptions { - LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } -} +#[cfg(test)] +use annotations::{MAX_ANNOTATION_BYTES, MAX_LINK_ATTACHMENT_TEXT_BYTES, MAX_REMOTE_FILE_SPEC_BYTES, retained}; +use core::Inspector; +pub use core::{check_pages, inspect, pretty_date}; +#[cfg(test)] +use core::{inspect_structure, spend}; +pub use model::{ + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, + PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, +}; +#[cfg(test)] +use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; +pub use resources::attachment_data; +#[cfg(test)] +use resources::{xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -118,2365 +85,5 @@ pub struct DocInfo { pub warnings: Vec, } -/// What kind of XFA form a document has (`/AcroForm /XFA`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Xfa { - /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold - /// its data, which Acrobat shows in preference to the fields). - Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a - /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. - Dynamic, -} - -/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes -/// into view in a full-screen presentation, and how long it stays before advancing. Only pages -/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the -/// defaults rather than being refused. -#[derive(Clone, Debug, PartialEq)] -pub struct PageTransition { - pub page: usize, - /// `/Trans /S`; `None` when the page has only `/Dur`. - pub style: Option, - /// `/D`: how long the transition takes, in seconds (default 1). - pub duration: f32, - /// `/Dm /H` (true) or `/V` (Split, Blinds). - pub horizontal: bool, - /// `/M /I` (true) or `/O` (Split, Box, Fly). - pub inward: bool, - /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` - /// for `/None` (Fly only). - pub direction: Option, - /// `/SS`: the Fly scale (default 1). - pub scale: f32, - /// `/B`: Fly with an opaque backdrop. - pub opaque: bool, - /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the - /// presenter. - pub display: Option, -} - -/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionStyle { - Split, - Blinds, - Box, - Wipe, - Dissolve, - Glitter, - /// `/R`: the next page replaces the old one with no effect (the default). - Replace, - Fly, - Push, - Cover, - Uncover, - Fade, -} - -impl TransitionStyle { - /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. - pub fn from_name(name: &str) -> Self { - match name { - "Split" => Self::Split, - "Blinds" => Self::Blinds, - "Box" => Self::Box, - "Wipe" => Self::Wipe, - "Dissolve" => Self::Dissolve, - "Glitter" => Self::Glitter, - "Fly" => Self::Fly, - "Push" => Self::Push, - "Cover" => Self::Cover, - "Uncover" => Self::Uncover, - "Fade" => Self::Fade, - _ => Self::Replace, - } - } - - /// The name as written in a PDF (`/S`). - pub fn name(self) -> &'static str { - match self { - Self::Split => "Split", - Self::Blinds => "Blinds", - Self::Box => "Box", - Self::Wipe => "Wipe", - Self::Dissolve => "Dissolve", - Self::Glitter => "Glitter", - Self::Replace => "R", - Self::Fly => "Fly", - Self::Push => "Push", - Self::Cover => "Cover", - Self::Uncover => "Uncover", - Self::Fade => "Fade", - } - } -} - -#[derive(Clone, Debug)] -pub struct PageInfo { - /// Displayed size in points, after `/Rotate` and `/UserUnit`. - pub width: f32, - pub height: f32, - /// Page label (`/PageLabels`), falling back to the 1-based page number. - pub label: String, - /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). - pub crop: [f32; 4], - /// Clockwise page rotation in degrees (0, 90, 180, 270). - pub rotation: u16, -} - -impl PageInfo { - /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → - /// PDF user space. - pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); - let (u, v) = match self.rotation { - 90 => (b, 1.0 - a), - 180 => (1.0 - a, 1.0 - b), - 270 => (1.0 - b, a), - _ => (a, b), - }; - [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] - } - - /// PDF user space → view space (the inverse of [`Self::view_to_user`]). - pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); - let (a, b) = match self.rotation { - 90 => (1.0 - v, u), - 180 => (1.0 - u, 1.0 - v), - 270 => (v, 1.0 - u), - _ => (u, v), - }; - [a * self.width, b * self.height] - } - - /// A point as displayed (points from the page's top-left, y down, before any view rotation) - /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. - /// Only a coordinate that is given is pinned: a bookmark made at the top of the window - /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides - /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). - /// A value that is not finite counts as not given. - pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { - let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); - let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); - if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } - } - - /// A destination's user-space point (each coordinate possibly unspecified) → the same point - /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), - /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page - /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output - /// is `None` when the input it comes from is (or is not finite). Points off the page are - /// clamped to its edge. - pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { - let [cx0, cy0, cx1, cy1] = self.crop; - let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; - // Unrotated fractions across (u, from the crop's left) and down (v, from its top). - let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); - let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); - let flip = |f: Option| f.map(|f| 1.0 - f); - match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { - 90 => [flip(v), u], - 180 => [flip(u), flip(v)], - 270 => [v, flip(u)], - _ => [u, v], - } - } - - /// A view-space rectangle → the normalized user-space rectangle covering the same area of the - /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, - /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. - pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { - let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - } - - /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, - /// bottom-left, bottom-right as read on screen. - pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { - let mut q = [0.0; 8]; - for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { - let [ux, uy] = self.view_to_user(x, y); - q[2 * i] = ux as f64; - q[2 * i + 1] = uy as f64; - } - q - } -} - -/// A clickable link annotation. -#[derive(Clone, Debug)] -pub struct Link { - pub page: usize, - /// Zero-based position in the page's raw `/Annots` array. - pub index: usize, - pub rect: [f32; 4], - pub target: LinkTarget, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LinkTarget { - /// A destination in this document: the 0-based page and where on it the view goes. - Page(usize, DestView), - Uri(String), - /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in - /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned - /// on turns off the other layers of its radio-button groups. - SetLayers { - changes: Vec<(LayerOp, (u32, u16))>, - preserve_rb: bool, - }, - /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. - /// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same - /// way: as a document in PdfCraft, never by the operating system. - RemoteGoTo { - launch: bool, - file: String, - destination: RemoteDestination, - new_window: Option, - }, - Other(String), -} - -#[derive(Clone, Debug, PartialEq)] -pub enum RemoteDestination { - Default, - Page { page: usize, view: DestView }, - Named(String), -} - -/// What a set-layer-visibility action does to a layer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LayerOp { - On, - Off, - Toggle, -} - -/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the -/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps -/// that part of the view as it is. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DestView { - /// The top of the page at the current zoom: a destination with no position, or one whose - /// type is missing or unknown. - #[default] - Top, - /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` - /// (1.0 = 100%; 0 or null keeps the current zoom). - Xyz { left: Option, top: Option, zoom: Option }, - /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). - Fit, - /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. - FitH { top: Option }, - /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. - FitV { left: Option }, - /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). - FitR { rect: [f32; 4] }, -} - -impl DestView { - /// The view an explicit destination array asks for, from its elements after the page - /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown - /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, - /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. - pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { - let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); - match kind { - Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, - Some(b"Fit" | b"FitB") => Self::Fit, - Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, - Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, - Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { - (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, - _ => Self::Top, - }, - _ => Self::Top, - } - } -} - -#[derive(Clone, Debug)] -pub struct OutlineItem { - pub title: String, - pub page: Option, - /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). - pub view: DestView, - pub children: Vec, - /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, - /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with - /// children should not have. - pub open: bool, - /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the - /// default black or a value that isn't three numbers. - pub color: Option<[f32; 3]>, - /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. - pub italic: bool, - pub bold: bool, -} - -#[derive(Clone, Debug)] -pub struct Annotation { - pub page: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`). - pub name: Option, - pub in_reply_to: Option, - /// The page and `/Annots` position of the comment `/IRT` points at, found by object - /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF - /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed - /// comment ([`link_replies`]). - pub parent: Option<(usize, usize)>, - /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its - /// strikeout) rather than a reply to it. - pub grouped: bool, - /// Rect in PDF user space: [x0, y0, x1, y1]. - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - /// Position in the page's `/Annots` (how edits address the comment). - pub index: usize, - /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. - pub state: Option, - /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. - pub quads: Vec<[f32; 8]>, - /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. - pub locked: bool, - /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… - pub intent: Option, - /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture - /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). - pub fill_sign: bool, - /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the - /// appearance stays upright, turned about the upper-left corner of `rect` - /// ([`Self::shown_rect`]). - pub no_rotate: bool, - /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a - /// cloud (their corners, `/Vertices`), in user space; empty for other comments. - pub points: Vec<[f32; 2]>, -} - -impl Annotation { - /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. - pub fn is_mark(&self) -> bool { - matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) - } - - /// A reply (or status reply) to another comment rather than a comment of its own. - pub fn is_reply(&self) -> bool { - self.parent.is_some() - } - - /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't - /// be found (deleted from another page, or never listed) has nothing to show, so it is listed - /// nowhere: not as a card, on the page or in a count. - pub fn is_comment(&self) -> bool { - !self.is_reply() && self.state.is_none() - } - - /// Whether this annotation's `/IRT` points at `other`. - pub fn replies_to(&self, other: &Annotation) -> bool { - self.parent == Some((other.page, other.index)) - } - - /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user - /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner - /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment - /// use this, so they match the drawing. - pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { - self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) - } - - /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or - /// resized). - pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { upright_rect(rect, rotation) } else { rect } - } - - /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of - /// [`Self::shown`], for a box resized as drawn. - pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { - if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } - } -} - -/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned -/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its -/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's -/// top-left is that corner, the rectangle's width runs across and its height down, as on an -/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored -/// renderer draws the appearance there (hayro-interpret patch 37). -fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { - let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (w, h) = (x1 - x0, y1 - y0); - match rotation { - 90 => [x0, y1, x0 + h, y1 + w], - 180 => [x0 - w, y1, x0, y1 + h], - 270 => [x0 - h, y1 - w, x0, y1], - _ => [x0, y0, x1, y1], - } -} - -/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that -/// stays put. -fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { - let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; - let (sw, sh) = (s2 - s0, s3 - s1); - // The upper-left corner of `/Rect` and the rectangle's width and height. - let ((x0, y1), w, h) = match rotation { - 90 => ((s0, s1), sh, sw), - 180 => ((s2, s1), sw, sh), - 270 => ((s2, s3), sh, sw), - _ => return [s0, s1, s2, s3], - }; - [x0, y1 - h, x0 + w, y1] -} - -/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. -/// -/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other -/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an -/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list -/// has them. -pub fn link_replies(annotations: &mut [Annotation]) { - let at: HashMap<(usize, usize), usize> = - annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); - let links: Vec> = - annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; - } - } - if !settled { - a.parent = None; - } - } -} - -/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. -pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { - let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; - } - (reply, top) - }) - .collect() -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldKind { - Text, - CheckBox, - Radio, - PushButton, - Combo, - List, - Signature, - Unknown, -} - -#[derive(Clone, Debug)] -pub struct Field { - pub name: String, - pub kind: FieldKind, - pub value: Option, - pub page: Option, - pub tooltip: Option, - pub has_actions: bool, - /// Rect of the first widget, in user space. - pub rect: Option<[f32; 4]>, -} - -#[derive(Clone, Debug)] -pub struct Layer { - /// Object number and generation of the optional content group. - pub id: (u32, u16), - pub name: String, - pub visible: bool, - /// Shown as the file opens: the default configuration (`/OCProperties /D`). - pub initial: bool, -} - -#[derive(Clone, Debug)] -pub struct Attachment { - pub name: String, - pub description: Option, - pub size: Option, - pub source: AttachmentSource, -} - -/// Where an attachment lives (so its bytes can be fetched on demand). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AttachmentSource { - /// Document-level: `/Names /EmbeddedFiles` entry with this key. - Document { key: String }, - /// A FileAttachment annotation: page index and position in the page's `/Annots`. - Annotation { page: usize, index: usize }, -} - -/// A font used by the document (Document Properties ▸ Fonts). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FontInfo { - /// BaseFont without the subset tag. - pub name: String, - /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … - pub kind: String, - pub embedded: bool, - pub subset: bool, - pub encoding: Option, - /// The face standing in for a program the document does not embed. `None` when the font is - /// embedded (nothing stands in) or when no face was available at all -- `substitution` then - /// says which of the two it is. - pub substitute: Option, - /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when - /// the document's own glyphs are being shown. - pub substitution: Option, -} - -/// Check that `bytes` still open as the same document: the parser reads them and the page -/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) -/// but must still catch an edit that produced something the viewer cannot display, so the -/// caller can roll it back. Never panics: parser crashes are caught and reported. -pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) - .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; - if pages != expected { - return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); - } - Ok(()) -} - -/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a -/// password). Never panics: parser crashes are caught and reported. -pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { - let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) - .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? - .map_err(|e| match e { - LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { - if password.is_some() { - OpenError::WrongPassword - } else { - OpenError::NeedsPassword - } - } - LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), - LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), - })?; - let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; - let pages = catch_unwind(AssertUnwindSafe(|| { - let mut out = Vec::new(); - for (i, page) in pdf.pages().iter().enumerate() { - let (w, h) = page.render_dimensions(); - let c = page.intersected_crop_box(); - let rotation = match page.rotation() { - hayro::hayro_syntax::page::Rotation::None => 0, - hayro::hayro_syntax::page::Rotation::Horizontal => 90, - hayro::hayro_syntax::page::Rotation::Flipped => 180, - hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, - }; - let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; - // Degenerate boxes still get a usable placeholder size so layout never divides by zero. - let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; - out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); - } - out - })); - match pages { - Ok(p) => info.pages = p, - Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), - } - let options = load_options(password); - // Geometry no longer needs the renderer parser. Do not overlap its allocations with - // structural inspection, including the compatibility fallback for damaged files. - drop(pdf); - inspect_structure(&mut info, |tmp| { - // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same - // page tree or an object a panel needs. No partial result is published. - let lazy = catch_unwind(AssertUnwindSafe(|| { - let doc = LazyStructure::new(bytes.clone(), password).ok()?; - let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; - let inspector = Inspector::new(&doc); - if inspector.page_index.len() != t.pages.len() { - return None; - } - inspector.fill(&mut t); - (!doc.failed()).then_some(t) - })) - .ok() - .flatten(); - if let Some(t) = lazy { - *tmp = t; - return Ok(()); - } - let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; - Inspector::new(&doc).fill(tmp); - Ok(()) - }); - if info.pages.is_empty() { - return Err(OpenError::Invalid("the document has no pages".into())); - } - Ok(info) -} - -// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. -fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { - let structure = catch_unwind(AssertUnwindSafe(|| { - let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; - fill(&mut tmp)?; - Ok::<_, String>(tmp) - })); - match structure { - Ok(Ok(mut filled)) => { - filled.file_size = info.file_size; - filled.pdf_version = std::mem::take(&mut info.pdf_version); - *info = filled; - } - Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), - Err(p) => { - info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); - } - } -} - -struct Inspector<'a> { - doc: &'a dyn Structure, - page_ids: Vec, - page_index: HashMap, - /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. - /// Looking each name up by walking the tree was O(links × names): minutes on manuals. - named: HashMap, &'a Object>, - /// The page of each annotation (the first page that lists it), built when a field's widget - /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × - /// annotations): a second on a form of 8,000 fields. - annotation_pages: OnceCell>, -} - -impl<'a> Inspector<'a> { - fn new(doc: &'a dyn Structure) -> Self { - let page_ids = doc.page_ids(); - let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; - let mut named = HashMap::new(); - if let Some(tree) = - doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) - { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); - } - me.named = named; - me - } - - fn fill(&self, info: &mut DocInfo) { - // lopdf drops /Encrypt from the trailer once it has decrypted the file. - info.encrypted = self.doc.encrypted(); - if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { - info.title = self.text(d, b"Title"); - info.author = self.text(d, b"Author"); - info.subject = self.text(d, b"Subject"); - info.keywords = self.text(d, b"Keywords"); - info.creator = self.text(d, b"Creator"); - info.producer = self.text(d, b"Producer"); - const STANDARD: [&[u8]; 9] = - [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; - // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, - // trimmed and without empty values): text strings only, so a name value is not a - // custom property, and the cap counts listed entries. - info.custom = d - .iter() - .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) - .filter_map(|(k, v)| match self.resolve(v) { - Object::String(bytes, _) => { - let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); - (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) - } - _ => None, - }) - .take(256) - .collect(); - } - let Ok(catalog) = self.doc.catalog() else { return }; - info.tagged = catalog - .get(b"MarkInfo") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|m| m.get(b"Marked").ok()) - // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the - // flag is resolved before it is read as a boolean (#3671). - .map(|o| self.resolve(o)) - .and_then(|o| o.as_bool().ok()) - .unwrap_or(false); - match self.page_labels(catalog, &mut info.pages) { - Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), - Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), - } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } - let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; - self.annotations(info, &mut text_budget); - self.transitions(info); - let mut field_seen = HashSet::new(); - if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) - && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) - { - for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); - } - } - // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); - // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. - for &pid in &self.page_ids { - let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; - let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; - for a in annots { - let Some(d) = self.dict(a) else { continue }; - if self.name(d, b"Subtype").as_deref() != Some("Widget") - || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) - { - continue; - } - if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) - { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); - } - } - } - info.has_javascript |= info.fields.iter().any(|f| f.has_actions); - let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { - // A form with no fields is taken to be laid out from its template, unless its - // configuration says it is a static form (#3180): Designer writes - // `forbidden` for one, whose pages are the form. - let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); - info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); - } - self.layers(catalog, &mut info.layers); - info.layer_groups = self.layer_groups(catalog); - self.fonts(&mut info.fonts); - if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { - if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { - let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { - // The key is kept a second time when it is also the name. - let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); - info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); - } - } - info.has_javascript |= names.get(b"JavaScript").is_ok(); - } - if let Ok(action) = catalog.get(b"OpenAction") - && let Some(dict) = self.dict(action) - && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) - { - info.has_javascript = true; - } - info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); - } - - /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit - /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array - /// or an action) a destination name or string; a name or string is looked up as a named - /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and - /// neither has a page number past the end of the document. - fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { - let dest = match self.resolve(action) { - Object::Dictionary(d) => { - if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { - return None; - } - d.get(b"D").ok()? - } - other => other, - }; - self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) - } - - // ── object helpers ────────────────────────────────────────────────────────────────────── - - /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value - /// outside the spec's choices takes that entry's default, and a page with neither entry is - /// not listed. Bounded by the page count. - fn transitions(&self, info: &mut DocInfo) { - for (page, &pid) in self.page_ids.iter().enumerate() { - let Ok(d) = self.doc.get_dictionary(pid) else { continue }; - let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); - let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); - if trans.is_none() && display.is_none() { - continue; - } - let mut t = PageTransition { - page, - style: None, - duration: 1.0, - horizontal: true, - inward: true, - direction: Some(0), - scale: 1.0, - opaque: false, - display, - }; - if let Some(tr) = trans { - t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); - let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); - t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); - t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); - t.inward = self.name(tr, b"M").as_deref() != Some("O"); - t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) if n.as_slice() == b"None" => None, - Ok(o) => match o.as_i64().ok() { - Some(0) => Some(0), - Some(90) => Some(90), - Some(180) => Some(180), - Some(270) => Some(270), - Some(315) => Some(315), - _ => Some(0), - }, - Err(_) => Some(0), - }; - t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); - t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); - } - info.transitions.push(t); - } - } - - /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under - /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is - /// either one stream holding the whole XDP or an array of packet names and streams (ISO - /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over - /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. - fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { - let stream = match self.resolve(xfa) { - Object::Array(items) => { - let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; - self.resolve(items.get(at.checked_add(1)?)?) - } - whole => whole, - }; - let stream = stream.as_stream().ok()?; - let bytes = if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? - } else { - stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() - }; - xfa_config_dynamic_render(&xfa_packet_text(&bytes)) - } - - fn resolve(&self, o: &'a Object) -> &'a Object { - self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) - } - - /// The object a reference ends at after following any chain of references; `None` for a - /// direct object or a broken reference. - fn resolved_id(&self, o: &Object) -> Option { - self.doc.dereference(o).ok().and_then(|(id, _)| id) - } - - fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { - match self.resolve(o) { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - } - } - - fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) - } - - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims - /// short or to nothing still spends its raw length. - fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { - let o = self.resolve(d.get(key).ok()?); - let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; - let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; - (!text.is_empty()).then_some(text) - } - - fn name(&self, d: &Dictionary, key: &[u8]) -> Option { - self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) - } - - /// 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, - } - } - - fn page_of(&self, o: &Object) -> Option { - match o { - Object::Reference(id) => self.page_index.get(id).copied(), - // Some producers write page *numbers* in remote-style destinations. - Object::Integer(n) => usize::try_from(*n).ok(), - _ => None, - } - } - - // ── destinations ──────────────────────────────────────────────────────────────────────── - - /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), - /// a dictionary with `/D`, or a named destination that resolves to one. - fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { - if depth > 8 { - return None; - } - match self.resolve(dest) { - Object::Array(a) => { - let page = a.first().and_then(|p| self.page_of(p))?; - let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); - let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); - Some((page, DestView::from_operands(kind, &operands))) - } - Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), - Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), - _ => None, - } - } - - fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { - let catalog = self.doc.catalog().ok()?; - if let Some(v) = self.named.get(key) { - return Some(v); - } - // PDF 1.1 style /Dests dictionary. - catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) - } - - /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). - fn name_tree_raw(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>) { - if depth > 32 || !seen.insert(node as *const _) { - return; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), - _ => continue, - }; - out.entry(key).or_insert(v); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); - } - } - } - } - - /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` - /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. - fn name_tree( - &self, - node: &'a Dictionary, - seen: &mut HashSet<*const Dictionary>, - depth: u32, - text_budget: &mut usize, - ) -> Vec<(String, &'a Object)> { - let mut out = Vec::new(); - if depth > 32 || !seen.insert(node as *const _) { - return out; - } - if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - let key = match self.resolve(k) { - Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { - Some(key) => key, - None => continue, - }, - _ => String::new(), - }; - out.push((key, v)); - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1, text_budget)); - } - } - } - out - } - - /// The first entry of an embedded-files name tree whose key is `key`, in the order - /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key - /// object or `/Names` array that several places reference only once (a second visit cannot - /// match, since the first would have returned). - fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { - if depth > 32 || !seen.nodes.insert(node as *const _) { - return None; - } - let names = node.get(b"Names").ok(); - if let Some(names) = names - && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) - && let Object::Array(pairs) = self.resolve(names) - { - for pair in pairs.chunks(2) { - if let [k, v] = pair { - if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { - continue; - } - let found = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes) == key, - _ => key.is_empty(), - }; - if found { - return Some(v); - } - } - } - } - if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { - for k in kids { - if let Some(d) = self.dict(k) - && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) - { - return Some(v); - } - } - } - None - } - - // ── outline ───────────────────────────────────────────────────────────────────────────── - - /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, - /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - fn outline_window(&self, first: &'a Object) -> (Vec, bool) { - let mut seen = HashSet::new(); - // The listed bookmarks in outline order, with their depth. - let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); - // One entry per open level: its next sibling, or `None` once that run of siblings has ended. - let mut levels: Vec> = vec![Some(first)]; - let mut more = false; - while let Some(depth) = levels.len().checked_sub(1) { - let Some(slot) = levels.get_mut(depth) else { break }; - let Some(o) = slot.take() else { - levels.pop(); - continue; - }; - if let Object::Reference(id) = o - && !seen.insert(*id) - { - continue; // cycle: this run of siblings ends here - } - let Some(d) = self.dict(o) else { continue }; - if listed.len() >= OUTLINE_WINDOW { - more = true; - break; - } - *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - match d.get(b"First").ok() { - Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), - Some(_) => more = true, - None => {} - } - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - let title = self.text(d, b"Title").unwrap_or_default(); - let color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), - _ => None, - }; - let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); - let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); - } - (nest_outline(listed), more) - } - - // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── - - fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { - self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") - } - - fn label_dict(&self, object: &'a Object) -> Result, &'static str> { - Ok(match self.label_object(object)? { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }) - } - - /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. - /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, - /// a range whose labels are too long to show). - fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { - let mut notes = Vec::new(); - let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; - let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; - let mut ranges: BTreeMap = BTreeMap::new(); - let mut stack = vec![(tree, 0usize)]; - let mut seen = HashSet::new(); - let mut left = MAX_LABEL_TREE_WORK; - while let Some((node, depth)) = stack.pop() { - label_work(&mut left, 1)?; - if depth > MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - if !seen.insert(node as *const Dictionary) { - return Err("the number tree repeats a node or contains a cycle"); - } - if let Ok(nums) = node.get(b"Nums") - && let Object::Array(nums) = self.label_object(nums)? - { - label_work(&mut left, nums.len().div_ceil(2))?; - for pair in nums.chunks(2) { - if let [k, v] = pair - && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) - && let Ok(start) = usize::try_from(start) - && ranges.insert(start, d).is_some() - { - // The later range wins, as in readers that overwrite. - notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); - } - } - } - if let Ok(kids) = node.get(b"Kids") - && let Object::Array(kids) = self.label_object(kids)? - { - label_work(&mut left, kids.len())?; - if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { - return Err("the number tree has too many pending nodes"); - } - if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { - return Err("the number tree is too deep"); - } - for kid in kids { - if let Some(d) = self.label_dict(kid)? { - stack.push((d, depth + 1)); - } - } - } - } - // Decode each bounded prefix once, rather than once for every page in its range. - let mut specs = BTreeMap::new(); - let mut prefix_total = 0usize; - for (start, d) in ranges { - let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; - let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { - Some(Object::String(raw, _)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // A label prefix is literal text, not trimmed document metadata. - text_string(raw).trim_matches('\0').to_string() - } - Some(Object::Name(raw)) => { - if raw.len() > MAX_PREFIX_BYTES { - return Err("a raw prefix exceeds 2050 bytes"); - } - // Retain the existing name compatibility and boundary-NUL handling. - String::from_utf8_lossy(raw).trim_matches('\0').to_string() - } - _ => String::new(), - }; - if prefix.len() > MAX_LABEL_BYTES { - return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); - } - // A prefix object shared by many ranges is decoded once per range: count every copy. - prefix_total = prefix_total.saturating_add(prefix.len()); - if prefix_total > MAX_PREFIX_TOTAL_BYTES { - return Err("decoded prefixes exceed 4 MiB in total"); - } - let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); - specs.insert(start, (first, prefix, style)); - } - let mut staged = Vec::new(); - let mut total = 0usize; - for (i, _) in pages.iter().enumerate() { - let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; - let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { - let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; - first.checked_add(offset).ok_or("a page number overflows")? - } else { - 0 // /St is unused when the label consists only of its prefix. - }; - let remaining = MAX_LABEL_BYTES - prefix.len(); - let number = match *style { - Some(b"D") => Some(n.to_string()), - Some(b"R" | b"r") => roman(n, remaining), - Some(b"A" | b"a") => alpha(n, remaining), - _ => Some(String::new()), - } - .filter(|number| number.len() <= remaining); - // A label too long to show keeps that page's physical number; other ranges stay. - let Some(mut number) = number else { - let note = format!( - "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", - start.saturating_add(1) - ); - if !notes.contains(¬e) { - notes.push(note); - } - continue; - }; - if matches!(*style, Some(b"R" | b"A")) { - number.make_ascii_uppercase(); - } - let mut label = prefix.clone(); - label.push_str(&number); - if label.is_empty() { - continue; // Keep the existing physical number for an empty custom label. - } - total = total.checked_add(label.len()).ok_or("the total label size overflows")?; - if total > MAX_LABEL_TOTAL_BYTES { - return Err("custom labels exceed 4 MiB in total"); - } - staged.push((i, label)); - } - // A malformed later range must not leave a partially applied custom label sequence. - for (i, label) in staged { - if let Some(page) = pages.get_mut(i) { - page.label = label; - } - } - Ok(notes) - } - - // ── annotations ───────────────────────────────────────────────────────────────────────── - - /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. - 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 }; - 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 mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // 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)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => 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); - } - - 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 { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("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()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } - }; - Some(Link { page, index, rect, target }) - } - - 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 ───────────────────────────────────────────────────────────────────────── - - fn field( - &self, - o: &'a Object, - parent: Option<(&str, Option, Option)>, - out: &mut Vec, - seen: &mut HashSet, - depth: u32, - ) { - if depth > 32 { - return; - } - if let Object::Reference(id) = o - && !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: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); - if !field_kids.is_empty() { - for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); - } - 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. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - 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)); - 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 }); - } - - /// 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`), or `None` when it or an - /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. - 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)?; - let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => 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; - } - } - - // ── optional content ──────────────────────────────────────────────────────────────────── - - fn layers(&self, catalog: &Dictionary, out: &mut Vec) { - let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; - let config = props.get(b"D").ok().and_then(|o| self.dict(o)); - let off: HashSet = config - .and_then(|c| c.get(b"OFF").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); - let on: HashSet = config - .and_then(|c| c.get(b"ON").ok()) - .and_then(|o| self.resolve(o).as_array().ok()) - .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) - .unwrap_or_default(); - let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; - for g in ocgs { - let Some(d) = self.dict(g) else { continue }; - let id = g.as_reference().ok(); - let visible = match id { - Some(id) if off.contains(&id) => false, - Some(id) if on.contains(&id) => true, - _ => !base_off, - }; - let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); - } - } - - /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. - fn layer_groups(&self, catalog: &Dictionary) -> Vec> { - let groups = catalog - .get(b"OCProperties") - .ok() - .and_then(|o| self.dict(o)) - .and_then(|p| p.get(b"D").ok()) - .and_then(|o| self.dict(o)) - .and_then(|c| c.get(b"RBGroups").ok()) - .and_then(|o| self.resolve(o).as_array().ok()); - let mut left = MAX_LAYER_GROUP_ENTRIES; - let mut out = Vec::new(); - for g in groups.into_iter().flatten() { - let Ok(members) = self.resolve(g).as_array() else { continue }; - let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); - left = left.saturating_sub(group.len()); - if group.len() > 1 { - out.push(group); - } - if left == 0 { - break; - } - } - out - } - - fn fonts(&self, out: &mut Vec) { - let mut seen = HashSet::new(); - for &pid in &self.page_ids { - let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; - for (_, f) in fonts { - let base = f - .get(b"BaseFont") - .ok() - .and_then(|o| o.as_name().ok()) - .map(|n| String::from_utf8_lossy(n).into_owned()) - .unwrap_or_else(|| "(unnamed)".into()); - let kind = self.name(f, b"Subtype").unwrap_or_default(); - let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { - Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), - Ok(Object::Dictionary(_)) => Some("Custom".into()), - Ok(Object::Stream(_)) => Some("Embedded CMap".into()), - _ => None, - }; - // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); - let descendant = - f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); - let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); - let embedded = - kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); - // What the substitution chain needs beyond the name: the descriptor flags, and for - // a composite font the script its descendant declares (ISO 32000-2 9.7.3). - let flags = desc - .and_then(|d| d.get(b"Flags").ok()) - .and_then(|o| self.resolve(o).as_i64().ok()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0); - let ordering = - descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); - let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); - let name = if subset { base[7..].to_string() } else { base }; - let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { - base_font: &name, - embedded, - flags, - ordering: ordering.as_deref(), - }); - let info = FontInfo { - name, - kind, - embedded, - subset, - encoding, - substitute: chosen.face_name(), - substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), - }; - if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { - out.push(info); - } - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - } - - /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. - fn attachment( - &self, - spec: &Object, - source: AttachmentSource, - text_budget: &mut usize, - fallback: impl FnOnce(&mut usize) -> Option, - ) -> Attachment { - let d = self.dict(spec); - let size = - d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( - |f| match self.resolve(f) { - Object::Stream(s) => s - .dict - .get(b"Params") - .ok() - .and_then(|p| self.dict(p)) - .and_then(|p| p.get(b"Size").ok()) - .and_then(|s| s.as_i64().ok()) - .map(|s| s as usize) - .or(Some(s.content.len())), - _ => None, - }, - ); - let display = d - .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) - .or_else(|| fallback(text_budget)) - .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } - } -} - -/// A text string's value, decoded by the same rules as the engine's object model -/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. -fn text_string(bytes: &[u8]) -> String { - pdfcraft_cos::PdfString::literal(bytes).to_text() -} - -/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` -/// array objects it reached by reference. -#[derive(Default)] -struct NameTreeSeen { - nodes: HashSet<*const Dictionary>, - objects: HashSet, -} - -/// A string or name value as text, without surrounding NULs and white space. -fn trimmed_text(o: &Object) -> Option { - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - Some(s.trim_matches('\0').trim().to_string()) -} - -/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw -/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes -/// out too long spends the raw length, so a string many entries share is converted only until the -/// budget runs out. -fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { - if bytes.len() > *budget { - return None; - } - let text = convert(bytes); - if text.len() > *budget { - *budget = budget.saturating_sub(bytes.len()); - return None; - } - // The conversion's work follows the raw length, even when the text it keeps is shorter. - *budget = budget.saturating_sub(text.len().max(bytes.len())); - Some(text) -} - -fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { - if bytes.len() > max_bytes { - return None; - } - let text = text_string(bytes); - (text.len() <= max_bytes).then_some(text) -} - -/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, -/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in -/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give -/// `None`. -pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { - let v: Vec> = values.take(4).collect(); - let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; - // A number past the range, however large, is kept at its edge. - let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); - (rgb != [0.0; 3]).then_some(rgb) -} - -/// Nests a pre-order list of `(depth, item)` into the outline tree. -fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { - let mut roots = Vec::new(); - // The open ancestors, outermost first; each collects its children until it closes. - let mut open: Vec<(usize, OutlineItem)> = Vec::new(); - for (depth, item) in listed { - while open.last().is_some_and(|(d, _)| *d >= depth) { - close_outline(&mut open, &mut roots); - } - open.push((depth, item)); - } - while !open.is_empty() { - close_outline(&mut open, &mut roots); - } - roots -} - -/// Closes the innermost open bookmark: it joins its parent's children, or the top level. -fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { - if let Some((_, item)) = open.pop() { - match open.last_mut() { - Some((_, parent)) => parent.children.push(item), - None => roots.push(item), - } - } -} - -/// 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], - } -} - -fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { - *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; - Ok(()) -} - -/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. -/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. -pub fn pretty_date(s: &str) -> String { - let d = s.trim_start_matches("D:"); - if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { - // Twelve ASCII digits make every slice below a UTF-8 character boundary. - format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) - } else { - s.to_string() - } -} - -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. -pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { - let run = || -> Result, String> { - let options = load_options(password); - let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; - let insp = Inspector::new(&doc); - let spec: &Object = match &att.source { - AttachmentSource::Document { key } => { - let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; - let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? - } - AttachmentSource::Annotation { page, index } => { - let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; - let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; - let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; - insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? - } - }; - let fs = insp.dict(spec).ok_or("bad file specification")?; - let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; - let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; - let stream = stream.as_stream().map_err(|e| e.to_string())?; - if stream.dict.get(b"Filter").is_ok() { - stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) - } else { - Ok(stream.content.clone()) - } - }; - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) -} - -/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by -/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one -/// element is looked for. -fn xfa_packet_text(bytes: &[u8]) -> String { - let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { - let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); - char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() - }; - match bytes { - [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), - [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), - [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), - [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), - [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), - _ => String::from_utf8_lossy(bytes).into_owned(), - } -} - -/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it -/// closes (``) or closes itself (``), and its name without a namespace prefix. -struct Tag<'a> { - start: usize, - end: usize, - close: bool, - empty: bool, - local: &'a str, -} - -/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, -/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one -/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. -fn next_tag(xml: &str, mut from: usize) -> Option> { - loop { - let lt = from + xml.get(from..)?.find('<')?; - let rest = xml.get(lt..)?; - let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - if rest.starts_with("")?; - continue; - } - let gt = lt + rest.find('>')?; - let inner = xml.get(lt + 1..gt)?; - let (close, inner) = match inner.strip_prefix('/') { - Some(i) => (true, i), - None => (false, inner), - }; - let empty = !close && inner.ends_with('/'); - let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); - let local = name.rsplit(':').next().unwrap_or(name); - return Some(Tag { start: lt, end: gt + 1, close, empty, local }); - } -} - -/// What `…` holds: the content of the first top-level element of `xml` called `name` -/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, -/// it closes itself, or it is never closed. -fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { - let mut at = 0; - // The elements open around `at`, innermost last: a closing tag must name the innermost one, - // or the markup is malformed and has no value. - let mut open_tags: Vec<&str> = Vec::new(); - // Where the wanted element's content starts, once its opening tag is seen at the top level. - let mut open = None; - loop { - let tag = next_tag(xml, at)?; - at = tag.end; - if tag.close { - if open_tags.pop()? != tag.local { - return None; - } - if open_tags.is_empty() - && let Some(start) = open - { - return xml.get(start..tag.start); - } - } else if !tag.empty { - if open_tags.is_empty() && tag.local == name { - open = Some(tag.end); - } - open_tags.push(tag.local); - } else if open_tags.is_empty() && tag.local == name { - return None; - } - } -} - -/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the -/// one before) in `xml`, if it is plain text. -fn xml_path_text(xml: &str, path: &[&str]) -> Option { - let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; - let text = body.trim(); - (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) -} - -/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child -/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside -/// the form data or the template are not the configuration. -fn xfa_config_dynamic_render(packet: &str) -> Option { - const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; - xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) -} - #[cfg(test)] mod tests; - -/// "1.7" from the parser's version name ("Pdf17", "V1_7", …). -fn version_label(name: &str) -> String { - let digits: Vec = name.chars().filter(char::is_ascii_digit).collect(); - match digits[..] { - [a, b] => format!("{a}.{b}"), - _ => name.to_owned(), - } -} - -#[cfg(test)] -mod version_tests { - #[test] - fn version_labels_read_as_numbers() { - assert_eq!(super::version_label("Pdf17"), "1.7"); - assert_eq!(super::version_label("V2_0"), "2.0"); - assert_eq!(super::version_label("Unknown"), "Unknown"); - } -} - -#[cfg(test)] -mod substitution_tests { - use std::sync::Arc; - - use super::{FontInfo, inspect}; - - /// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a - /// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased - /// standard-14 name. - const NOT_EMBEDDED: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj -4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj -5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj -6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj -7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj -8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj -9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj -10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj -11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj -12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj -trailer << /Size 13 /Root 1 0 R >> -%%EOF"; - - fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo { - fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::>())) - } - - #[test] - fn doc_info_says_which_face_stands_in_and_why() { - let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens"); - - // The one that matters most to a user: a Chinese PDF that draws nothing. Before this, - // doc_info said only `embedded: false` and left them to guess. - let cjk = font(&info.fonts, "STSong-Light"); - assert!(!cjk.embedded); - assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in"); - let why = cjk.substitution.as_deref().unwrap_or_default(); - assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}"); - - // An aliased standard-14 name resolves to the real face, whose metrics are exact. - let arial = font(&info.fonts, "ArialMT"); - assert_eq!(arial.substitute.as_deref(), Some("Helvetica")); - assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14")); - - // A house font whose name merely contains "Serif" gets Times -- reported as the guess it - // is, not as a standard-14 name, which would be untrue of a font nobody has heard of. - let serif = font(&info.fonts, "SomeHouseSerif"); - assert_eq!(serif.substitute.as_deref(), Some("Times-Roman")); - assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}"); - - // No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic. - let grotesk = font(&info.fonts, "HouseGrotesk"); - assert_eq!(grotesk.substitute.as_deref(), Some("Courier")); - assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}"); - - // Japanese depends on the optional craft-fonts build input; either way it is reported, - // and never as a Latin face. - let jpan = font(&info.fonts, "KozMinPro-Regular"); - assert!(jpan.substitution.is_some(), "a substitution is always explained"); - match &jpan.substitute { - Some(face) => { - assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}") - } - None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")), - } - - // Nothing claims to be embedded, so every font here carries a reason. - assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts); - } - - /// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain. - const EMBEDDED: &[u8] = b"%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj -4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj -5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj -6 0 obj << /Length 4 >> stream -test -endstream endobj -trailer << /Size 7 /Root 1 0 R >> -%%EOF"; - - #[test] - fn an_embedded_font_reports_no_substitution_at_all() { - let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens"); - let f = font(&info.fonts, "SomeHouseSans"); - assert!(f.embedded && f.subset, "{f:?}"); - assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); - } -} - -#[cfg(test)] -mod open_action_javascript_tests { - use std::sync::Arc; - - use super::inspect; - - /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller). - fn pdf_with_open_action(action: &[u8]) -> Vec { - let objs: Vec<&[u8]> = vec![ - b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>", - b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", - action, - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(o); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out - } - - #[test] - fn open_action_javascript_sets_has_javascript() { - let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>"); - let info = inspect(Arc::new(bytes), None).expect("inspects"); - assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript"); - } - - #[test] - fn open_action_goto_does_not_set_has_javascript() { - let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>"); - let info = inspect(Arc::new(bytes), None).expect("inspects"); - assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); - } -} - -#[cfg(test)] -mod open_dest_tests { - use std::sync::Arc; - - use super::{DestView, inspect}; - - /// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and - /// `extra` become objects 6, 7, …. - fn pdf(catalog: &str, extra: &[String]) -> Vec { - let mut objs = - vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()]; - for _ in 0..3 { - objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); - } - objs.extend(extra.iter().cloned()); - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out - } - - fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> { - inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest - } - - const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) }; - - #[test] - fn an_explicit_destination_opens_at_its_page_and_position() { - assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ))); - assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) }))); - // Indirect, and a zoom of 0 keeps the zoom (Table 149). - assert_eq!( - open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]), - Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None })) - ); - } - - #[test] - fn a_go_to_action_opens_at_its_destination_explicit_or_named() { - assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); - // A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4). - assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); - // A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D. - assert_eq!( - open_dest( - "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>", - &["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()] - ), - Some((2, XYZ)) - ); - } - - #[test] - fn an_open_action_given_as_a_name_or_string_is_looked_up() { - assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); - assert_eq!( - open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]), - Some((2, DestView::FitV { left: Some(30.0) })) - ); - } - - #[test] - fn an_open_action_that_goes_nowhere_here_has_no_destination() { - for catalog in [ - "", - "/OpenAction << /S /JavaScript /JS (app.alert(1)) >>", - // A remote go-to's /D is a page of another file. - "/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>", - "/OpenAction << /D [4 0 R /Fit] >>", - // An unknown name, and no name dictionaries at all. - "/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>", - "/OpenAction /missing", - // A page number past the end, and a page reference that is no page. - "/OpenAction [7 /Fit]", - "/OpenAction [2 0 R /Fit]", - "/OpenAction 42", - ] { - assert_eq!(open_dest(catalog, &[]), None, "{catalog}"); - } - } - - #[test] - fn cyclic_and_deep_named_destinations_end() { - // Names that refer to each other. - assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None); - assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None); - // A name tree whose node is its own kid. - assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None); - // A name tree deeper than the depth bound: the leaf past it isn't read. - let depth = 40; - let mut nodes: Vec = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect(); - nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into()); - assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None); - // A shallow one is read. - let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; - assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); - } -} diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs new file mode 100644 index 0000000000..07283c0473 --- /dev/null +++ b/crates/render/src/inspect/annotations.rs @@ -0,0 +1,474 @@ +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::*; + +/// 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 link listing, and name, description and embedded-file key text the +/// attachment listing, keep together. Many links or attachments can share one long string, which +/// each would otherwise copy. Past it, a 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; + +/// 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 }; + 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 mut color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) if c.len() == 3 => { + let f = |i: usize| c[i].as_float().unwrap_or(0.0); + Some([f(0), f(1), f(2)]) + } + _ => None, + }; + // 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)]); + } + } + let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { + Ok(Object::Array(q)) => { + let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + v.as_chunks::<8>().0.to_vec() + } + _ => 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); + } + + 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 { + let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; + match self.name(a, b"S").as_deref() { + Some("GoTo") => { + let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; + LinkTarget::Page(page, view) + } + Some("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()), + } + } + Some("SetOCGState") => self.layer_state(a), + Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), + Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), + Some(other) => LinkTarget::Other(other.to_string()), + None => return None, + } + }; + Some(Link { page, index, rect, target }) + } + + 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, + seen: &mut HashSet, + depth: u32, + ) { + if depth > 32 { + return; + } + if let Object::Reference(id) = o + && !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: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); + if !field_kids.is_empty() { + for k in field_kids { + self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); + } + 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. + let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; + 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)); + 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 }); + } + + /// 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`), 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)?; + let parent = match d.get(b"Parent") { + Ok(Object::Reference(p)) => 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; + } + } +} + +/// 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], + } +} diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs new file mode 100644 index 0000000000..875d873654 --- /dev/null +++ b/crates/render/src/inspect/core.rs @@ -0,0 +1,417 @@ +use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; +use super::model::{AttachmentSource, PageInfo, Xfa}; +use super::navigation::text_string; +use super::*; + +/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is +/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and +/// xref streams are far below this. +const LOAD_STREAM_LIMIT: usize = 256 << 20; + +pub(super) fn load_options(password: Option<&str>) -> LoadOptions { + LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } +} + +/// Check that `bytes` still open as the same document: the parser reads them and the page +/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files) +/// but must still catch an edit that produced something the viewer cannot display, so the +/// caller can roll it back. Never panics: parser crashes are caught and reported. +pub fn check_pages(bytes: Arc>, password: Option<&str>, expected: usize) -> Result<(), OpenError> { + let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or("")))) + .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? + .map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { + if password.is_some() { + OpenError::WrongPassword + } else { + OpenError::NeedsPassword + } + } + LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), + LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), + })?; + let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len())) + .map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?; + if pages != expected { + return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}"))); + } + Ok(()) +} + +/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a +/// password). Never panics: parser crashes are caught and reported. +pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result { + let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")))) + .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))? + .map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => { + if password.is_some() { + OpenError::WrongPassword + } else { + OpenError::NeedsPassword + } + } + LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")), + LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()), + })?; + let mut info = DocInfo { file_size: bytes.len(), pdf_version: version_label(&format!("{:?}", pdf.version())), ..Default::default() }; + let pages = catch_unwind(AssertUnwindSafe(|| { + let mut out = Vec::new(); + for (i, page) in pdf.pages().iter().enumerate() { + let (w, h) = page.render_dimensions(); + let c = page.intersected_crop_box(); + let rotation = match page.rotation() { + hayro::hayro_syntax::page::Rotation::None => 0, + hayro::hayro_syntax::page::Rotation::Horizontal => 90, + hayro::hayro_syntax::page::Rotation::Flipped => 180, + hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270, + }; + let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32]; + // Degenerate boxes still get a usable placeholder size so layout never divides by zero. + let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) }; + out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation }); + } + out + })); + match pages { + Ok(p) => info.pages = p, + Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), + } + let options = load_options(password); + // Geometry no longer needs the renderer parser. Do not overlap its allocations with + // structural inspection, including the compatibility fallback for damaged files. + drop(pdf); + inspect_structure(&mut info, |tmp| { + // The lazy reader first; lopdf's repair/tolerance path when it can't expose the same + // page tree or an object a panel needs. No partial result is published. + let lazy = catch_unwind(AssertUnwindSafe(|| { + let doc = LazyStructure::new(bytes.clone(), password).ok()?; + let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() }; + let inspector = Inspector::new(&doc); + if inspector.page_index.len() != t.pages.len() { + return None; + } + inspector.fill(&mut t); + (!doc.failed()).then_some(t) + })) + .ok() + .flatten(); + if let Some(t) = lazy { + *tmp = t; + return Ok(()); + } + let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?; + Inspector::new(&doc).fill(tmp); + Ok(()) + }); + if info.pages.is_empty() { + return Err(OpenError::Invalid("the document has no pages".into())); + } + Ok(info) +} + +// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback. +pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) { + let structure = catch_unwind(AssertUnwindSafe(|| { + let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() }; + fill(&mut tmp)?; + Ok::<_, String>(tmp) + })); + match structure { + Ok(Ok(mut filled)) => { + filled.file_size = info.file_size; + filled.pdf_version = std::mem::take(&mut info.pdf_version); + *info = filled; + } + Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")), + Err(p) => { + info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p))); + } + } +} + +pub(super) struct Inspector<'a> { + pub(super) doc: &'a dyn Structure, + pub(super) page_ids: Vec, + pub(super) page_index: HashMap, + /// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once. + /// Looking each name up by walking the tree was O(links × names): minutes on manuals. + pub(super) named: HashMap, &'a Object>, + /// The page of each annotation (the first page that lists it), built when a field's widget + /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × + /// annotations): a second on a form of 8,000 fields. + pub(super) annotation_pages: OnceCell>, +} + +impl<'a> Inspector<'a> { + pub(super) fn new(doc: &'a dyn Structure) -> Self { + let page_ids = doc.page_ids(); + let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; + let mut named = HashMap::new(); + if let Some(tree) = + doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) + { + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + } + me.named = named; + me + } + + pub(super) fn fill(&self, info: &mut DocInfo) { + // lopdf drops /Encrypt from the trailer once it has decrypted the file. + info.encrypted = self.doc.encrypted(); + if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) { + info.title = self.text(d, b"Title"); + info.author = self.text(d, b"Author"); + info.subject = self.text(d, b"Subject"); + info.keywords = self.text(d, b"Keywords"); + info.creator = self.text(d, b"Creator"); + info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); + } + let Ok(catalog) = self.doc.catalog() else { return }; + info.tagged = catalog + .get(b"MarkInfo") + .ok() + .and_then(|o| self.dict(o)) + .and_then(|m| m.get(b"Marked").ok()) + // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the + // flag is resolved before it is read as a boolean (#3671). + .map(|o| self.resolve(o)) + .and_then(|o| o.as_bool().ok()) + .unwrap_or(false); + match self.page_labels(catalog, &mut info.pages) { + Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), + Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), + } + if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { + (info.outline, info.outline_more) = self.outline_window(first); + } + let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + self.annotations(info, &mut text_budget); + self.transitions(info); + let mut field_seen = HashSet::new(); + if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) + && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) + { + for f in fields { + self.field(f, None, &mut info.fields, &mut field_seen, 0); + } + } + // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); + // each is adopted through its topmost unlisted `/Parent`, so a split field stays one. + for &pid in &self.page_ids { + let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue }; + let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; + for a in annots { + let Some(d) = self.dict(a) else { continue }; + if self.name(d, b"Subtype").as_deref() != Some("Widget") + || (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err()) + { + continue; + } + if let Object::Reference(id) = a + && let Some(root) = self.unlisted_field(*id, &field_seen) + { + self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); + } + } + } + info.has_javascript |= info.fields.iter().any(|f| f.has_actions); + let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); + if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { + // A form with no fields is taken to be laid out from its template, unless its + // configuration says it is a static form (#3180): Designer writes + // `forbidden` for one, whose pages are the form. + let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); + info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); + } + self.layers(catalog, &mut info.layers); + info.layer_groups = self.layer_groups(catalog); + self.fonts(&mut info.fonts); + if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { + if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { + let mut seen = HashSet::new(); + for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { + // The key is kept a second time when it is also the name. + let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); + info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); + } + } + info.has_javascript |= names.get(b"JavaScript").is_ok(); + } + if let Ok(action) = catalog.get(b"OpenAction") + && let Some(dict) = self.dict(action) + && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) + { + info.has_javascript = true; + } + info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); + } + + pub(super) fn resolve(&self, o: &'a Object) -> &'a Object { + self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) + } + + /// The object a reference ends at after following any chain of references; `None` for a + /// direct object or a broken reference. + pub(super) fn resolved_id(&self, o: &Object) -> Option { + self.doc.dereference(o).ok().and_then(|(id, _)| id) + } + + pub(super) fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { + match self.resolve(o) { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + } + } + + pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option { + let s = trimmed_text(self.resolve(d.get(key).ok()?))?; + (!s.is_empty()).then_some(s) + } + + /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims + /// short or to nothing still spends its raw length. + pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { + let o = self.resolve(d.get(key).ok()?); + let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; + let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; + (!text.is_empty()).then_some(text) + } + + pub(super) fn name(&self, d: &Dictionary, key: &[u8]) -> Option { + self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) + } +} + +/// A string or name value as text, without surrounding NULs and white space. +fn trimmed_text(o: &Object) -> Option { + let s = match o { + Object::String(bytes, _) => text_string(bytes), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + Some(s.trim_matches('\0').trim().to_string()) +} + +/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw +/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes +/// out too long spends the raw length, so a string many entries share is converted only until the +/// budget runs out. +pub(super) fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { + if bytes.len() > *budget { + return None; + } + let text = convert(bytes); + if text.len() > *budget { + *budget = budget.saturating_sub(bytes.len()); + return None; + } + // The conversion's work follows the raw length, even when the text it keeps is shorter. + *budget = budget.saturating_sub(text.len().max(bytes.len())); + Some(text) +} + +pub(super) fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { + if bytes.len() > max_bytes { + return None; + } + let text = text_string(bytes); + (text.len() <= max_bytes).then_some(text) +} + +/// `D:20260930104512-04'00'` → `2026-09-30 10:45`. +/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged. +pub fn pretty_date(s: &str) -> String { + let d = s.trim_start_matches("D:"); + if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) { + // Twelve ASCII digits make every slice below a UTF-8 character boundary. + format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12]) + } else { + s.to_string() + } +} + +/// "1.7" from the parser's version name ("Pdf17", "V1_7", …). +fn version_label(name: &str) -> String { + let digits: Vec = name.chars().filter(char::is_ascii_digit).collect(); + match digits[..] { + [a, b] => format!("{a}.{b}"), + _ => name.to_owned(), + } +} + +#[cfg(test)] +mod version_tests { + #[test] + fn version_labels_read_as_numbers() { + assert_eq!(super::version_label("Pdf17"), "1.7"); + assert_eq!(super::version_label("V2_0"), "2.0"); + assert_eq!(super::version_label("Unknown"), "Unknown"); + } +} + +#[cfg(test)] +mod open_action_javascript_tests { + use std::sync::Arc; + + use super::inspect; + + /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller). + fn pdf_with_open_action(action: &[u8]) -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + action, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + #[test] + fn open_action_javascript_sets_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript"); + } + + #[test] + fn open_action_goto_does_not_set_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); + } +} diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs new file mode 100644 index 0000000000..e5f09c9242 --- /dev/null +++ b/crates/render/src/inspect/model.rs @@ -0,0 +1,539 @@ +use super::*; + +/// What kind of XFA form a document has (`/AcroForm /XFA`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Xfa { + /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold + /// its data, which Acrobat shows in preference to the fields). + Static, + /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a + /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. + Dynamic, +} + +/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes +/// into view in a full-screen presentation, and how long it stays before advancing. Only pages +/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the +/// defaults rather than being refused. +#[derive(Clone, Debug, PartialEq)] +pub struct PageTransition { + pub page: usize, + /// `/Trans /S`; `None` when the page has only `/Dur`. + pub style: Option, + /// `/D`: how long the transition takes, in seconds (default 1). + pub duration: f32, + /// `/Dm /H` (true) or `/V` (Split, Blinds). + pub horizontal: bool, + /// `/M /I` (true) or `/O` (Split, Box, Fly). + pub inward: bool, + /// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None` + /// for `/None` (Fly only). + pub direction: Option, + /// `/SS`: the Fly scale (default 1). + pub scale: f32, + /// `/B`: Fly with an opaque backdrop. + pub opaque: bool, + /// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the + /// presenter. + pub display: Option, +} + +/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransitionStyle { + Split, + Blinds, + Box, + Wipe, + Dissolve, + Glitter, + /// `/R`: the next page replaces the old one with no effect (the default). + Replace, + Fly, + Push, + Cover, + Uncover, + Fade, +} + +impl TransitionStyle { + /// The style a `/S` name means; an unknown name is `Replace`, the spec's default. + pub fn from_name(name: &str) -> Self { + match name { + "Split" => Self::Split, + "Blinds" => Self::Blinds, + "Box" => Self::Box, + "Wipe" => Self::Wipe, + "Dissolve" => Self::Dissolve, + "Glitter" => Self::Glitter, + "Fly" => Self::Fly, + "Push" => Self::Push, + "Cover" => Self::Cover, + "Uncover" => Self::Uncover, + "Fade" => Self::Fade, + _ => Self::Replace, + } + } + + /// The name as written in a PDF (`/S`). + pub fn name(self) -> &'static str { + match self { + Self::Split => "Split", + Self::Blinds => "Blinds", + Self::Box => "Box", + Self::Wipe => "Wipe", + Self::Dissolve => "Dissolve", + Self::Glitter => "Glitter", + Self::Replace => "R", + Self::Fly => "Fly", + Self::Push => "Push", + Self::Cover => "Cover", + Self::Uncover => "Uncover", + Self::Fade => "Fade", + } + } +} + +#[derive(Clone, Debug)] +pub struct PageInfo { + /// Displayed size in points, after `/Rotate` and `/UserUnit`. + pub width: f32, + pub height: f32, + /// Page label (`/PageLabels`), falling back to the 1-based page number. + pub label: String, + /// Effective crop box in user space [x0, y0, x1, y1] (the visible region). + pub crop: [f32; 4], + /// Clockwise page rotation in degrees (0, 90, 180, 270). + pub rotation: u16, +} + +impl PageInfo { + /// A point in view space (points, y down, after `/Rotate`; what the text layer uses) → + /// PDF user space. + pub fn view_to_user(&self, x: f32, y: f32) -> [f32; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let (a, b) = (x / self.width.max(1e-3), y / self.height.max(1e-3)); + let (u, v) = match self.rotation { + 90 => (b, 1.0 - a), + 180 => (1.0 - a, 1.0 - b), + 270 => (1.0 - b, a), + _ => (a, b), + }; + [cx0 + u * (cx1 - cx0), cy1 - v * (cy1 - cy0)] + } + + /// PDF user space → view space (the inverse of [`Self::view_to_user`]). + pub fn user_to_view(&self, x: f32, y: f32) -> [f32; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let (u, v) = ((x - cx0) / (cx1 - cx0).max(1e-3), (cy1 - y) / (cy1 - cy0).max(1e-3)); + let (a, b) = match self.rotation { + 90 => (1.0 - v, u), + 180 => (1.0 - u, 1.0 - v), + 270 => (v, 1.0 - u), + _ => (u, v), + }; + [a * self.width, b * self.height] + } + + /// A point as displayed (points from the page's top-left, y down, before any view rotation) + /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. + /// Only a coordinate that is given is pinned: a bookmark made at the top of the window + /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides + /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). + /// A value that is not finite counts as not given. + pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { + let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); + let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); + if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } + } + + /// A destination's user-space point (each coordinate possibly unspecified) → the same point + /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), + /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page + /// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output + /// is `None` when the input it comes from is (or is not finite). Points off the page are + /// clamped to its edge. + pub fn dest_fraction(&self, x: Option, y: Option, view_rotation: u16) -> [Option; 2] { + let [cx0, cy0, cx1, cy1] = self.crop; + let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None }; + // Unrotated fractions across (u, from the crop's left) and down (v, from its top). + let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3))); + let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3))); + let flip = |f: Option| f.map(|f| 1.0 - f); + match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 { + 90 => [flip(v), u], + 180 => [flip(u), flip(v)], + 270 => [v, flip(u)], + _ => [u, v], + } + } + + /// A view-space rectangle → the normalized user-space rectangle covering the same area of the + /// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles, + /// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`. + pub fn view_rect_to_user(&self, r: [f32; 4]) -> [f32; 4] { + let (a, b) = (self.view_to_user(r[0], r[1]), self.view_to_user(r[2], r[3])); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + } + + /// A view-space rectangle as a text-markup quad in user space: top-left, top-right, + /// bottom-left, bottom-right as read on screen. + pub fn view_rect_to_quad(&self, r: [f32; 4]) -> [f64; 8] { + let mut q = [0.0; 8]; + for (i, (x, y)) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].into_iter().enumerate() { + let [ux, uy] = self.view_to_user(x, y); + q[2 * i] = ux as f64; + q[2 * i + 1] = uy as f64; + } + q + } +} + +/// A clickable link annotation. +#[derive(Clone, Debug)] +pub struct Link { + pub page: usize, + /// Zero-based position in the page's raw `/Annots` array. + pub index: usize, + pub rect: [f32; 4], + pub target: LinkTarget, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LinkTarget { + /// A destination in this document: the 0-based page and where on it the view goes. + Page(usize, DestView), + Uri(String), + /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in + /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned + /// on turns off the other layers of its radio-button groups. + SetLayers { + changes: Vec<(LayerOp, (u32, u16))>, + preserve_rb: bool, + }, + /// A local-file remote Go-To action. The caller resolves `file` relative to the source path. + /// `launch` marks a Launch action that names a PDF file (see `launch_pdf`), opened the same + /// way: as a document in PdfCraft, never by the operating system. + RemoteGoTo { + launch: bool, + file: String, + destination: RemoteDestination, + new_window: Option, + }, + Other(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RemoteDestination { + Default, + Page { page: usize, view: DestView }, + Named(String), +} + +/// What a set-layer-visibility action does to a layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayerOp { + On, + Off, + Toggle, +} + +/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the +/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps +/// that part of the view as it is. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum DestView { + /// The top of the page at the current zoom: a destination with no position, or one whose + /// type is missing or unknown. + #[default] + Top, + /// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom` + /// (1.0 = 100%; 0 or null keeps the current zoom). + Xyz { left: Option, top: Option, zoom: Option }, + /// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page). + Fit, + /// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge. + FitH { top: Option }, + /// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge. + FitV { left: Option }, + /// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized). + FitR { rect: [f32; 4] }, +} + +impl DestView { + /// The view an explicit destination array asks for, from its elements after the page + /// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown + /// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric, + /// non-finite or missing operand is `None`; a zoom that is not positive is `None`. + pub fn from_operands(kind: Option<&[u8]>, operands: &[Option]) -> Self { + let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite()); + match kind { + Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) }, + Some(b"Fit" | b"FitB") => Self::Fit, + Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) }, + Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) }, + Some(b"FitR") => match (n(0), n(1), n(2), n(3)) { + (Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] }, + _ => Self::Top, + }, + _ => Self::Top, + } + } +} + +#[derive(Clone, Debug)] +pub struct OutlineItem { + pub title: String, + pub page: Option, + /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). + pub view: DestView, + pub children: Vec, + /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, + /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with + /// children should not have. + pub open: bool, + /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the + /// default black or a value that isn't three numbers. + pub color: Option<[f32; 3]>, + /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. + pub italic: bool, + pub bold: bool, +} + +#[derive(Clone, Debug)] +pub struct Annotation { + pub page: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + pub modified: Option, + /// `/NM` of this annotation, and of the one it replies to (`/IRT`). + pub name: Option, + pub in_reply_to: Option, + /// The page and `/Annots` position of the comment `/IRT` points at, found by object + /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF + /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed + /// comment ([`link_replies`]). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its + /// strikeout) rather than a reply to it. + pub grouped: bool, + /// Rect in PDF user space: [x0, y0, x1, y1]. + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + /// Position in the page's `/Annots` (how edits address the comment). + pub index: usize, + /// For a status reply (`/State`, Acrobat's "Set status"): the state it sets. + pub state: Option, + /// Text markup quadrilaterals (`/QuadPoints`), 8 numbers each. + pub quads: Vec<[f32; 8]>, + /// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted. + pub locked: bool, + /// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`… + pub intent: Option, + /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture + /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). + pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the + /// appearance stays upright, turned about the upper-left corner of `rect` + /// ([`Self::shown_rect`]). + pub no_rotate: bool, + /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a + /// cloud (their corners, `/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, +} + +impl Annotation { + /// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status. + pub fn is_mark(&self) -> bool { + matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) + } + + /// A reply (or status reply) to another comment rather than a comment of its own. + pub fn is_reply(&self) -> bool { + self.parent.is_some() + } + + /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't + /// be found (deleted from another page, or never listed) has nothing to show, so it is listed + /// nowhere: not as a card, on the page or in a count. + pub fn is_comment(&self) -> bool { + !self.is_reply() && self.state.is_none() + } + + /// Whether this annotation's `/IRT` points at `other`. + pub fn replies_to(&self, other: &Annotation) -> bool { + self.parent == Some((other.page, other.index)) + } + + /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user + /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner + /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment + /// use this, so they match the drawing. + pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { + self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) + } + + /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or + /// resized). + pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { upright_rect(rect, rotation) } else { rect } + } + + /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of + /// [`Self::shown`], for a box resized as drawn. + pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } + } +} + +/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned +/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its +/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's +/// top-left is that corner, the rectangle's width runs across and its height down, as on an +/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored +/// renderer draws the appearance there (hayro-interpret patch 37). +fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { + let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (w, h) = (x1 - x0, y1 - y0); + match rotation { + 90 => [x0, y1, x0 + h, y1 + w], + 180 => [x0 - w, y1, x0, y1 + h], + 270 => [x0 - h, y1 - w, x0, y1], + _ => [x0, y0, x1, y1], + } +} + +/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that +/// stays put. +fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { + let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; + let (sw, sh) = (s2 - s0, s3 - s1); + // The upper-left corner of `/Rect` and the rectangle's width and height. + let ((x0, y1), w, h) = match rotation { + 90 => ((s0, s1), sh, sw), + 180 => ((s2, s1), sw, sh), + 270 => ((s2, s3), sh, sw), + _ => return [s0, s1, s2, s3], + }; + [x0, y1 - h, x0 + w, y1] +} + +/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when +/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted +/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays +/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list +/// after an edit) run this. +/// +/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other +/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an +/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list +/// has them. +pub fn link_replies(annotations: &mut [Annotation]) { + let at: HashMap<(usize, usize), usize> = + annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); + let links: Vec> = + annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); + for (i, a) in annotations.iter_mut().enumerate() { + let mut up = links.get(i).copied().flatten(); + let mut settled = false; + for _ in 0..64 { + let Some(j) = up else { break }; + up = links.get(j).copied().flatten(); + if up.is_none() { + settled = true; + break; + } + } + if !settled { + a.parent = None; + } + } +} + +/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { + let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); + parents + .iter() + .map(|(&reply, &first)| { + let mut top = first; + for _ in 0..64 { + let Some(&up) = parents.get(&top) else { break }; + top = up; + } + (reply, top) + }) + .collect() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldKind { + Text, + CheckBox, + Radio, + PushButton, + Combo, + List, + Signature, + Unknown, +} + +#[derive(Clone, Debug)] +pub struct Field { + pub name: String, + pub kind: FieldKind, + pub value: Option, + pub page: Option, + pub tooltip: Option, + pub has_actions: bool, + /// Rect of the first widget, in user space. + pub rect: Option<[f32; 4]>, +} + +#[derive(Clone, Debug)] +pub struct Layer { + /// Object number and generation of the optional content group. + pub id: (u32, u16), + pub name: String, + pub visible: bool, + /// Shown as the file opens: the default configuration (`/OCProperties /D`). + pub initial: bool, +} + +#[derive(Clone, Debug)] +pub struct Attachment { + pub name: String, + pub description: Option, + pub size: Option, + pub source: AttachmentSource, +} + +/// Where an attachment lives (so its bytes can be fetched on demand). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AttachmentSource { + /// Document-level: `/Names /EmbeddedFiles` entry with this key. + Document { key: String }, + /// A FileAttachment annotation: page index and position in the page's `/Annots`. + Annotation { page: usize, index: usize }, +} + +/// A font used by the document (Document Properties ▸ Fonts). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FontInfo { + /// BaseFont without the subset tag. + pub name: String, + /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 … + pub kind: String, + pub embedded: bool, + pub subset: bool, + pub encoding: Option, + /// The face standing in for a program the document does not embed. `None` when the font is + /// embedded (nothing stands in) or when no face was available at all -- `substitution` then + /// says which of the two it is. + pub substitute: Option, + /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when + /// the document's own glyphs are being shown. + pub substitution: Option, +} diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs new file mode 100644 index 0000000000..173217c93b --- /dev/null +++ b/crates/render/src/inspect/navigation.rs @@ -0,0 +1,550 @@ +#[cfg(test)] +use super::core::inspect; +use super::core::spend; +use super::model::{DestView, OutlineItem, PageInfo}; +use super::*; + +/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document +/// reads only this window; the rest are listed by page through `pdfcraft_organize`. +pub(super) const OUTLINE_WINDOW: usize = 10_000; + +/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. +pub(super) const OUTLINE_DEPTH: usize = 32; + +impl<'a> Inspector<'a> { + /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit + /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array + /// or an action) a destination name or string; a name or string is looked up as a named + /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and + /// neither has a page number past the end of the document. + pub(super) fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { + let dest = match self.resolve(action) { + Object::Dictionary(d) => { + if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { + return None; + } + d.get(b"D").ok()? + } + other => other, + }; + self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) + } + + pub(super) fn page_of(&self, o: &Object) -> Option { + match o { + Object::Reference(id) => self.page_index.get(id).copied(), + // Some producers write page *numbers* in remote-style destinations. + Object::Integer(n) => usize::try_from(*n).ok(), + _ => None, + } + } + + // ── destinations ──────────────────────────────────────────────────────────────────────── + + /// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …), + /// a dictionary with `/D`, or a named destination that resolves to one. + pub(super) fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> { + if depth > 8 { + return None; + } + match self.resolve(dest) { + Object::Array(a) => { + let page = a.first().and_then(|p| self.page_of(p))?; + let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok()); + let operands: Vec> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect(); + Some((page, DestView::from_operands(kind, &operands))) + } + Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)), + Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)), + _ => None, + } + } + + fn named_dest(&self, key: &[u8]) -> Option<&'a Object> { + let catalog = self.doc.catalog().ok()?; + if let Some(v) = self.named.get(key) { + return Some(v); + } + // PDF 1.1 style /Dests dictionary. + catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok()) + } + + /// A name tree's leaves keyed by their raw key bytes (first occurrence wins). + pub(super) fn name_tree_raw( + &self, + node: &'a Dictionary, + seen: &mut HashSet<*const Dictionary>, + depth: u32, + out: &mut HashMap, &'a Object>, + ) { + if depth > 32 || !seen.insert(node as *const _) { + return; + } + if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + let key = match self.resolve(k) { + Object::String(s, _) => s.clone(), + Object::Name(n) => n.clone(), + _ => continue, + }; + out.entry(key).or_insert(v); + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) { + self.name_tree_raw(d, seen, depth + 1, out); + } + } + } + } + + /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` + /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. + pub(super) fn name_tree( + &self, + node: &'a Dictionary, + seen: &mut HashSet<*const Dictionary>, + depth: u32, + text_budget: &mut usize, + ) -> Vec<(String, &'a Object)> { + let mut out = Vec::new(); + if depth > 32 || !seen.insert(node as *const _) { + return out; + } + if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + let key = match self.resolve(k) { + Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { + Some(key) => key, + None => continue, + }, + _ => String::new(), + }; + out.push((key, v)); + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) { + out.extend(self.name_tree(d, seen, depth + 1, text_budget)); + } + } + } + out + } + + /// The first entry of an embedded-files name tree whose key is `key`, in the order + /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key + /// object or `/Names` array that several places reference only once (a second visit cannot + /// match, since the first would have returned). + pub(super) fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { + if depth > 32 || !seen.nodes.insert(node as *const _) { + return None; + } + let names = node.get(b"Names").ok(); + if let Some(names) = names + && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) + && let Object::Array(pairs) = self.resolve(names) + { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { + continue; + } + let found = match self.resolve(k) { + Object::String(bytes, _) => text_string(bytes) == key, + _ => key.is_empty(), + }; + if found { + return Some(v); + } + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) + && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) + { + return Some(v); + } + } + } + None + } + + // ── outline ───────────────────────────────────────────────────────────────────────────── + + /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, + /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and + /// each bookmark is read once, so the work and memory stay within the window. + pub(super) fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + let mut seen = HashSet::new(); + // The listed bookmarks in outline order, with their depth. + let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); + // One entry per open level: its next sibling, or `None` once that run of siblings has ended. + let mut levels: Vec> = vec![Some(first)]; + let mut more = false; + while let Some(depth) = levels.len().checked_sub(1) { + let Some(slot) = levels.get_mut(depth) else { break }; + let Some(o) = slot.take() else { + levels.pop(); + continue; + }; + if let Object::Reference(id) = o + && !seen.insert(*id) + { + continue; // cycle: this run of siblings ends here + } + let Some(d) = self.dict(o) else { continue }; + if listed.len() >= OUTLINE_WINDOW { + more = true; + break; + } + *slot = d.get(b"Next").ok(); + let dest = d + .get(b"Dest") + .ok() + .and_then(|dest| self.dest(dest, 0)) + .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); + let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); + match d.get(b"First").ok() { + Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), + Some(_) => more = true, + None => {} + } + let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); + let title = self.text(d, b"Title").unwrap_or_default(); + let color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), + _ => None, + }; + let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); + let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); + listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); + } + (nest_outline(listed), more) + } + + // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── + + fn label_object(&self, object: &'a Object) -> Result<&'a Object, &'static str> { + self.doc.dereference(object).map(|(_, o)| o).map_err(|_| "a number tree reference is missing or cyclic") + } + + fn label_dict(&self, object: &'a Object) -> Result, &'static str> { + Ok(match self.label_object(object)? { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + }) + } + + /// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied. + /// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page, + /// a range whose labels are too long to show). + pub(super) fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result, &'static str> { + let mut notes = Vec::new(); + let Ok(tree) = catalog.get(b"PageLabels") else { return Ok(notes) }; + let Some(tree) = self.label_dict(tree)? else { return Ok(notes) }; + let mut ranges: BTreeMap = BTreeMap::new(); + let mut stack = vec![(tree, 0usize)]; + let mut seen = HashSet::new(); + let mut left = MAX_LABEL_TREE_WORK; + while let Some((node, depth)) = stack.pop() { + label_work(&mut left, 1)?; + if depth > MAX_LABEL_TREE_DEPTH { + return Err("the number tree is too deep"); + } + if !seen.insert(node as *const Dictionary) { + return Err("the number tree repeats a node or contains a cycle"); + } + if let Ok(nums) = node.get(b"Nums") + && let Object::Array(nums) = self.label_object(nums)? + { + label_work(&mut left, nums.len().div_ceil(2))?; + for pair in nums.chunks(2) { + if let [k, v] = pair + && let (Ok(start), Some(d)) = (self.label_object(k)?.as_i64(), self.label_dict(v)?) + && let Ok(start) = usize::try_from(start) + && ranges.insert(start, d).is_some() + { + // The later range wins, as in readers that overwrite. + notes.push(format!("a range starts on page {} twice; the later one is used", start.saturating_add(1))); + } + } + } + if let Ok(kids) = node.get(b"Kids") + && let Object::Array(kids) = self.label_object(kids)? + { + label_work(&mut left, kids.len())?; + if kids.len() > MAX_LABEL_TREE_WORK.saturating_sub(stack.len()) { + return Err("the number tree has too many pending nodes"); + } + if !kids.is_empty() && depth == MAX_LABEL_TREE_DEPTH { + return Err("the number tree is too deep"); + } + for kid in kids { + if let Some(d) = self.label_dict(kid)? { + stack.push((d, depth + 1)); + } + } + } + } + // Decode each bounded prefix once, rather than once for every page in its range. + let mut specs = BTreeMap::new(); + let mut prefix_total = 0usize; + for (start, d) in ranges { + let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64; + let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) { + Some(Object::String(raw, _)) => { + if raw.len() > MAX_PREFIX_BYTES { + return Err("a raw prefix exceeds 2050 bytes"); + } + // A label prefix is literal text, not trimmed document metadata. + text_string(raw).trim_matches('\0').to_string() + } + Some(Object::Name(raw)) => { + if raw.len() > MAX_PREFIX_BYTES { + return Err("a raw prefix exceeds 2050 bytes"); + } + // Retain the existing name compatibility and boundary-NUL handling. + String::from_utf8_lossy(raw).trim_matches('\0').to_string() + } + _ => String::new(), + }; + if prefix.len() > MAX_LABEL_BYTES { + return Err("a decoded prefix exceeds 1024 UTF-8 bytes"); + } + // A prefix object shared by many ranges is decoded once per range: count every copy. + prefix_total = prefix_total.saturating_add(prefix.len()); + if prefix_total > MAX_PREFIX_TOTAL_BYTES { + return Err("decoded prefixes exceed 4 MiB in total"); + } + let style = d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()); + specs.insert(start, (first, prefix, style)); + } + let mut staged = Vec::new(); + let mut total = 0usize; + for (i, _) in pages.iter().enumerate() { + let Some((&start, (first, prefix, style))) = specs.range(..=i).next_back() else { continue }; + let n = if matches!(*style, Some(b"D" | b"R" | b"r" | b"A" | b"a")) { + let offset = u64::try_from(i - start).map_err(|_| "a page number is too large")?; + first.checked_add(offset).ok_or("a page number overflows")? + } else { + 0 // /St is unused when the label consists only of its prefix. + }; + let remaining = MAX_LABEL_BYTES - prefix.len(); + let number = match *style { + Some(b"D") => Some(n.to_string()), + Some(b"R" | b"r") => roman(n, remaining), + Some(b"A" | b"a") => alpha(n, remaining), + _ => Some(String::new()), + } + .filter(|number| number.len() <= remaining); + // A label too long to show keeps that page's physical number; other ranges stay. + let Some(mut number) = number else { + let note = format!( + "labels from page {} exceed {MAX_LABEL_BYTES} UTF-8 bytes; those pages show their physical page numbers", + start.saturating_add(1) + ); + if !notes.contains(¬e) { + notes.push(note); + } + continue; + }; + if matches!(*style, Some(b"R" | b"A")) { + number.make_ascii_uppercase(); + } + let mut label = prefix.clone(); + label.push_str(&number); + if label.is_empty() { + continue; // Keep the existing physical number for an empty custom label. + } + total = total.checked_add(label.len()).ok_or("the total label size overflows")?; + if total > MAX_LABEL_TOTAL_BYTES { + return Err("custom labels exceed 4 MiB in total"); + } + staged.push((i, label)); + } + // A malformed later range must not leave a partially applied custom label sequence. + for (i, label) in staged { + if let Some(page) = pages.get_mut(i) { + page.label = label; + } + } + Ok(notes) + } +} + +/// A text string's value, decoded by the same rules as the engine's object model +/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree. +pub(super) fn text_string(bytes: &[u8]) -> String { + pdfcraft_cos::PdfString::literal(bytes).to_text() +} + +/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` +/// array objects it reached by reference. +#[derive(Default)] +pub(super) struct NameTreeSeen { + nodes: HashSet<*const Dictionary>, + objects: HashSet, +} + +/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, +/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in +/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give +/// `None`. +pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { + let v: Vec> = values.take(4).collect(); + let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; + // A number past the range, however large, is kept at its edge. + let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); + (rgb != [0.0; 3]).then_some(rgb) +} + +/// Nests a pre-order list of `(depth, item)` into the outline tree. +fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { + let mut roots = Vec::new(); + // The open ancestors, outermost first; each collects its children until it closes. + let mut open: Vec<(usize, OutlineItem)> = Vec::new(); + for (depth, item) in listed { + while open.last().is_some_and(|(d, _)| *d >= depth) { + close_outline(&mut open, &mut roots); + } + open.push((depth, item)); + } + while !open.is_empty() { + close_outline(&mut open, &mut roots); + } + roots +} + +/// Closes the innermost open bookmark: it joins its parent's children, or the top level. +fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { + if let Some((_, item)) = open.pop() { + match open.last_mut() { + Some((_, parent)) => parent.children.push(item), + None => roots.push(item), + } + } +} + +fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> { + *left = left.checked_sub(count).ok_or("the number tree has too many entries")?; + Ok(()) +} + +#[cfg(test)] +mod open_dest_tests { + use std::sync::Arc; + + use super::{DestView, inspect}; + + /// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and + /// `extra` become objects 6, 7, …. + fn pdf(catalog: &str, extra: &[String]) -> Vec { + let mut objs = + vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()]; + for _ in 0..3 { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); + } + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> { + inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest + } + + const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) }; + + #[test] + fn an_explicit_destination_opens_at_its_page_and_position() { + assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ))); + assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) }))); + // Indirect, and a zoom of 0 keeps the zoom (Table 149). + assert_eq!( + open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]), + Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None })) + ); + } + + #[test] + fn a_go_to_action_opens_at_its_destination_explicit_or_named() { + assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4). + assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D. + assert_eq!( + open_dest( + "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>", + &["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()] + ), + Some((2, XYZ)) + ); + } + + #[test] + fn an_open_action_given_as_a_name_or_string_is_looked_up() { + assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); + assert_eq!( + open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]), + Some((2, DestView::FitV { left: Some(30.0) })) + ); + } + + #[test] + fn an_open_action_that_goes_nowhere_here_has_no_destination() { + for catalog in [ + "", + "/OpenAction << /S /JavaScript /JS (app.alert(1)) >>", + // A remote go-to's /D is a page of another file. + "/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>", + "/OpenAction << /D [4 0 R /Fit] >>", + // An unknown name, and no name dictionaries at all. + "/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>", + "/OpenAction /missing", + // A page number past the end, and a page reference that is no page. + "/OpenAction [7 /Fit]", + "/OpenAction [2 0 R /Fit]", + "/OpenAction 42", + ] { + assert_eq!(open_dest(catalog, &[]), None, "{catalog}"); + } + } + + #[test] + fn cyclic_and_deep_named_destinations_end() { + // Names that refer to each other. + assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None); + assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None); + // A name tree whose node is its own kid. + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None); + // A name tree deeper than the depth bound: the leaf past it isn't read. + let depth = 40; + let mut nodes: Vec = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect(); + nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into()); + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None); + // A shallow one is read. + let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); + } +} diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs new file mode 100644 index 0000000000..0386c5781f --- /dev/null +++ b/crates/render/src/inspect/resources.rs @@ -0,0 +1,468 @@ +#[cfg(test)] +use super::core::inspect; +use super::core::{Inspector, load_options}; +use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; +use super::navigation::NameTreeSeen; +use super::*; + +/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to +/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. +const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; + +/// At most this many layers are read from all of `/RBGroups` together. +const MAX_LAYER_GROUP_ENTRIES: usize = 4096; + +impl<'a> Inspector<'a> { + // ── object helpers ────────────────────────────────────────────────────────────────────── + + /// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value + /// outside the spec's choices takes that entry's default, and a page with neither entry is + /// not listed. Bounded by the page count. + pub(super) fn transitions(&self, info: &mut DocInfo) { + for (page, &pid) in self.page_ids.iter().enumerate() { + let Ok(d) = self.doc.get_dictionary(pid) else { continue }; + let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0); + let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o)); + if trans.is_none() && display.is_none() { + continue; + } + let mut t = PageTransition { + page, + style: None, + duration: 1.0, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display, + }; + if let Some(tr) = trans { + t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n))); + let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite()); + t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0); + t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V"); + t.inward = self.name(tr, b"M").as_deref() != Some("O"); + t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) { + Ok(Object::Name(n)) if n.as_slice() == b"None" => None, + Ok(o) => match o.as_i64().ok() { + Some(0) => Some(0), + Some(90) => Some(90), + Some(180) => Some(180), + Some(270) => Some(270), + Some(315) => Some(315), + _ => Some(0), + }, + Err(_) => Some(0), + }; + t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0); + t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true))); + } + info.transitions.push(t); + } + } + + /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under + /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is + /// either one stream holding the whole XDP or an array of packet names and streams (ISO + /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over + /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. + pub(super) fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { + let stream = match self.resolve(xfa) { + Object::Array(items) => { + let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; + self.resolve(items.get(at.checked_add(1)?)?) + } + whole => whole, + }; + let stream = stream.as_stream().ok()?; + let bytes = if stream.dict.get(b"Filter").is_ok() { + stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? + } else { + stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() + }; + xfa_config_dynamic_render(&xfa_packet_text(&bytes)) + } + + // ── optional content ──────────────────────────────────────────────────────────────────── + + pub(super) fn layers(&self, catalog: &Dictionary, out: &mut Vec) { + let Some(props) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)) else { return }; + let config = props.get(b"D").ok().and_then(|o| self.dict(o)); + let off: HashSet = config + .and_then(|c| c.get(b"OFF").ok()) + .and_then(|o| self.resolve(o).as_array().ok()) + .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) + .unwrap_or_default(); + let base_off = config.and_then(|c| self.name(c, b"BaseState")).as_deref() == Some("OFF"); + let on: HashSet = config + .and_then(|c| c.get(b"ON").ok()) + .and_then(|o| self.resolve(o).as_array().ok()) + .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) + .unwrap_or_default(); + let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; + for g in ocgs { + let Some(d) = self.dict(g) else { continue }; + let id = g.as_reference().ok(); + let visible = match id { + Some(id) if off.contains(&id) => false, + Some(id) if on.contains(&id) => true, + _ => !base_off, + }; + let Some(id) = id else { continue }; + out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); + } + } + + /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. + pub(super) fn layer_groups(&self, catalog: &Dictionary) -> Vec> { + let groups = catalog + .get(b"OCProperties") + .ok() + .and_then(|o| self.dict(o)) + .and_then(|p| p.get(b"D").ok()) + .and_then(|o| self.dict(o)) + .and_then(|c| c.get(b"RBGroups").ok()) + .and_then(|o| self.resolve(o).as_array().ok()); + let mut left = MAX_LAYER_GROUP_ENTRIES; + let mut out = Vec::new(); + for g in groups.into_iter().flatten() { + let Ok(members) = self.resolve(g).as_array() else { continue }; + let group: Vec = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect(); + left = left.saturating_sub(group.len()); + if group.len() > 1 { + out.push(group); + } + if left == 0 { + break; + } + } + out + } + + pub(super) fn fonts(&self, out: &mut Vec) { + let mut seen = HashSet::new(); + for &pid in &self.page_ids { + let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue }; + for (_, f) in fonts { + let base = f + .get(b"BaseFont") + .ok() + .and_then(|o| o.as_name().ok()) + .map(|n| String::from_utf8_lossy(n).into_owned()) + .unwrap_or_else(|| "(unnamed)".into()); + let kind = self.name(f, b"Subtype").unwrap_or_default(); + let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) { + Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()), + Ok(Object::Dictionary(_)) => Some("Custom".into()), + Ok(Object::Stream(_)) => Some("Embedded CMap".into()), + _ => None, + }; + // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). + let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); + let descendant = + f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); + let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); + let embedded = + kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); + // What the substitution chain needs beyond the name: the descriptor flags, and for + // a composite font the script its descendant declares (ISO 32000-2 9.7.3). + let flags = desc + .and_then(|d| d.get(b"Flags").ok()) + .and_then(|o| self.resolve(o).as_i64().ok()) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let ordering = + descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); + let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); + let name = if subset { base[7..].to_string() } else { base }; + let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { + base_font: &name, + embedded, + flags, + ordering: ordering.as_deref(), + }); + let info = FontInfo { + name, + kind, + embedded, + subset, + encoding, + substitute: chosen.face_name(), + substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), + }; + if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { + out.push(info); + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + } + + /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. + pub(super) fn attachment( + &self, + spec: &Object, + source: AttachmentSource, + text_budget: &mut usize, + fallback: impl FnOnce(&mut usize) -> Option, + ) -> Attachment { + let d = self.dict(spec); + let size = + d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( + |f| match self.resolve(f) { + Object::Stream(s) => s + .dict + .get(b"Params") + .ok() + .and_then(|p| self.dict(p)) + .and_then(|p| p.get(b"Size").ok()) + .and_then(|s| s.as_i64().ok()) + .map(|s| s as usize) + .or(Some(s.content.len())), + _ => None, + }, + ); + let display = d + .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) + .or_else(|| fallback(text_budget)) + .unwrap_or_else(|| "attachment".into()); + Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } + } +} + +/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. +pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { + let run = || -> Result, String> { + let options = load_options(password); + let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; + let insp = Inspector::new(&doc); + let spec: &Object = match &att.source { + AttachmentSource::Document { key } => { + let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; + let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; + insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? + } + AttachmentSource::Annotation { page, index } => { + let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; + let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?; + let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?; + insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")? + } + }; + let fs = insp.dict(spec).ok_or("bad file specification")?; + let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?; + let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?; + let stream = stream.as_stream().map_err(|e| e.to_string())?; + if stream.dict.get(b"Filter").is_ok() { + stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string()) + } else { + Ok(stream.content.clone()) + } + }; + catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) +} + +/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by +/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one +/// element is looked for. +pub(super) fn xfa_packet_text(bytes: &[u8]) -> String { + let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { + let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() + }; + match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), + [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), + [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), + [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + _ => String::from_utf8_lossy(bytes).into_owned(), + } +} + +/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it +/// closes (``) or closes itself (``), and its name without a namespace prefix. +struct Tag<'a> { + start: usize, + end: usize, + close: bool, + empty: bool, + local: &'a str, +} + +/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, +/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one +/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. +fn next_tag(xml: &str, mut from: usize) -> Option> { + loop { + let lt = from + xml.get(from..)?.find('<')?; + let rest = xml.get(lt..)?; + let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); + if rest.starts_with("")?; + continue; + } + if rest.starts_with("")?; + continue; + } + if rest.starts_with("")?; + continue; + } + let gt = lt + rest.find('>')?; + let inner = xml.get(lt + 1..gt)?; + let (close, inner) = match inner.strip_prefix('/') { + Some(i) => (true, i), + None => (false, inner), + }; + let empty = !close && inner.ends_with('/'); + let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); + let local = name.rsplit(':').next().unwrap_or(name); + return Some(Tag { start: lt, end: gt + 1, close, empty, local }); + } +} + +/// What `…` holds: the content of the first top-level element of `xml` called `name` +/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, +/// it closes itself, or it is never closed. +fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let mut at = 0; + // The elements open around `at`, innermost last: a closing tag must name the innermost one, + // or the markup is malformed and has no value. + let mut open_tags: Vec<&str> = Vec::new(); + // Where the wanted element's content starts, once its opening tag is seen at the top level. + let mut open = None; + loop { + let tag = next_tag(xml, at)?; + at = tag.end; + if tag.close { + if open_tags.pop()? != tag.local { + return None; + } + if open_tags.is_empty() + && let Some(start) = open + { + return xml.get(start..tag.start); + } + } else if !tag.empty { + if open_tags.is_empty() && tag.local == name { + open = Some(tag.end); + } + open_tags.push(tag.local); + } else if open_tags.is_empty() && tag.local == name { + return None; + } + } +} + +/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the +/// one before) in `xml`, if it is plain text. +fn xml_path_text(xml: &str, path: &[&str]) -> Option { + let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; + let text = body.trim(); + (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) +} + +/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child +/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside +/// the form data or the template are not the configuration. +pub(super) fn xfa_config_dynamic_render(packet: &str) -> Option { + const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; + xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) +} + +#[cfg(test)] +mod substitution_tests { + use std::sync::Arc; + + use super::{FontInfo, inspect}; + + /// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a + /// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased + /// standard-14 name. + const NOT_EMBEDDED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj +5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj +6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj +7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj +8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj +9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj +10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj +11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj +12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj +trailer << /Size 13 /Root 1 0 R >> +%%EOF"; + + fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo { + fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::>())) + } + + #[test] + fn doc_info_says_which_face_stands_in_and_why() { + let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens"); + + // The one that matters most to a user: a Chinese PDF that draws nothing. Before this, + // doc_info said only `embedded: false` and left them to guess. + let cjk = font(&info.fonts, "STSong-Light"); + assert!(!cjk.embedded); + assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in"); + let why = cjk.substitution.as_deref().unwrap_or_default(); + assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}"); + + // An aliased standard-14 name resolves to the real face, whose metrics are exact. + let arial = font(&info.fonts, "ArialMT"); + assert_eq!(arial.substitute.as_deref(), Some("Helvetica")); + assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14")); + + // A house font whose name merely contains "Serif" gets Times -- reported as the guess it + // is, not as a standard-14 name, which would be untrue of a font nobody has heard of. + let serif = font(&info.fonts, "SomeHouseSerif"); + assert_eq!(serif.substitute.as_deref(), Some("Times-Roman")); + assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}"); + + // No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic. + let grotesk = font(&info.fonts, "HouseGrotesk"); + assert_eq!(grotesk.substitute.as_deref(), Some("Courier")); + assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}"); + + // Japanese depends on the optional craft-fonts build input; either way it is reported, + // and never as a Latin face. + let jpan = font(&info.fonts, "KozMinPro-Regular"); + assert!(jpan.substitution.is_some(), "a substitution is always explained"); + match &jpan.substitute { + Some(face) => { + assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}") + } + None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")), + } + + // Nothing claims to be embedded, so every font here carries a reason. + assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts); + } + + /// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain. + const EMBEDDED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj +5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj +6 0 obj << /Length 4 >> stream +test +endstream endobj +trailer << /Size 7 /Root 1 0 R >> +%%EOF"; + + #[test] + fn an_embedded_font_reports_no_substitution_at_all() { + let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens"); + let f = font(&info.fonts, "SomeHouseSans"); + assert!(f.embedded && f.subset, "{f:?}"); + assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); + } +}