//! Document inspection: everything panels need that is not pixels. //! //! Page geometry comes from hayro (which resolves inheritance and rotation); the rest comes from //! the lazy COS reader through a read-only lopdf object adapter. The original lopdf loader //! remains the compatibility fallback. When neither can inspect a renderable file, panels //! are empty and `warnings` says why. use std::cell::OnceCell; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use std::panic::{AssertUnwindSafe, catch_unwind}; use hayro::hayro_syntax::DecryptionError; use hayro::hayro_syntax::LoadPdfError; use hayro::hayro_syntax::Pdf; use lopdf::{Dictionary, Document, LoadOptions, Object, ObjectId}; use pdfcraft_cos::page_labels::{ MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, alpha, roman, }; 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; /// 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() } } #[derive(Clone, Debug, Default)] pub struct DocInfo { pub title: Option, pub author: Option, pub subject: Option, pub keywords: Option, pub creator: Option, pub producer: Option, /// Custom document properties: `/Info` entries with a key the spec does not name and a text /// value, as (name, text), in the dictionary's order (at most 256). pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, pub tagged: bool, pub has_javascript: bool, /// The form is (also) an XFA form, which isn't read yet (`form.xfa-*` in the parity list). pub xfa: Option, pub pages: Vec, pub outline: Vec, /// The outline has bookmarks that `outline` leaves out (see `OUTLINE_WINDOW`, `OUTLINE_DEPTH`). pub outline_more: bool, pub annotations: Vec, pub fields: Vec, pub links: Vec, pub layers: Vec, /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on /// turns the others off. pub layer_groups: Vec>, pub fonts: Vec, pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, /// named destinations resolved. `None` without one, for an action that goes nowhere in this /// document (a script, say), or for one that doesn't resolve to a page of it. pub open_dest: Option<(usize, DestView)>, 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, 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`), for threading. pub name: Option, pub in_reply_to: Option, /// 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")) } /// 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] } #[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. let mut seen_arrays = HashSet::new(); let mut seen_annotations = HashSet::new(); 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 (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) && !seen_annotations.insert(r) { continue; } 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 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, 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]))); } 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 { #[test] fn png_predictor_rows_longer_than_the_data_are_refused_before_allocating() { // Vendored lopdf patch: a fuzzed `/Columns 4294967295` allocated two 4 GiB rows before // reading any data. A row that cannot fit in the data is refused up front. let e = lopdf::filters::png::decode_frame(&[2, 0, 0, 0], 1, 64 << 20).unwrap_err(); assert!(e.to_string().contains("longer than the data"), "{e}"); // A real frame still decodes: two 3-byte rows, the second Up-filtered. assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]); } #[test] fn empty_png_predictor_frames_do_not_allocate_rows() { // usize::MAX cannot be reserved, so the old code safely errors before allocating. // An empty frame needs no rows regardless of the declared width. assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty()); } fn tiff_predictor_stream(data: Vec, columns: i64, colors: i64, bits: i64) -> lopdf::Stream { let mut params = lopdf::Dictionary::new(); params.set("Predictor", 2i64); params.set("Columns", columns); params.set("Colors", colors); params.set("BitsPerComponent", bits); let mut dict = lopdf::Dictionary::new(); dict.set("DecodeParms", params); let mut stream = lopdf::Stream::new(dict, data); stream.compress().unwrap(); assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode"); stream } #[test] fn tiff_subbyte_predictor_row_width_overflow_is_refused() { // Each multiplication overflows before any scratch allocation in the old debug build. for bits in [1, 2, 4] { let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits); assert!( matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))), "{bits}-bit row" ); } } #[test] fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() { // 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec // reject the capacity before allocating. A partial row has no preceding pixel. for bits in [1, 2] { let data = vec![0b1010_0110; 64]; let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits); assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row"); } } #[test] fn tiff_subbyte_predictors_keep_components_rows_and_padding() { // Repeated tiny rows compress through the public Stream API. Rows remain independent, // differences wrap at each component depth, and trailing padding bits survive. for (columns, colors, bits, encoded, decoded) in [ (16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]), (6, 1, 2, vec![85, 179], vec![108, 147]), (3, 1, 4, vec![25, 16], vec![26, 176]), (2, 2, 4, vec![18, 34], vec![18, 52]), ] { let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits); assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours"); } } // Xref-stream fixtures exercise indirect /Length lookup during document loading: stream 2's /Length is `3 0 R`, // `containers` lists compressed (id, container, index) entries and `objects` the other objects in the file. fn compressed_length_pdf(containers: &[(u32, u32, u16)], objects: &[(u32, &[u8])]) -> Vec { let mut pdf = b"%PDF-1.7\n".to_vec(); let ids = containers.iter().flat_map(|&(id, container, _)| [id, container]).chain(objects.iter().map(|&(id, _)| id)); let xref_id = ids.max().unwrap_or(3) + 1; let mut records = vec![[0u8; 7]; (xref_id + 1) as usize]; records[0] = [0, 0, 0, 0, 0, 255, 255]; let mut append = |id: u32, body: &[u8]| { records[id as usize][0] = 1; records[id as usize][1..5].copy_from_slice(&(pdf.len() as u32).to_be_bytes()); pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); pdf.extend_from_slice(body); pdf.extend_from_slice(b"\nendobj\n"); }; append(1, b"<< /Type /Catalog >>"); append(2, b"<< /Length 3 0 R >>\nstream\nhello\nendstream"); for &(id, body) in objects { append(id, body); } for &(id, container, index) in containers { records[id as usize][0] = 2; records[id as usize][1..5].copy_from_slice(&container.to_be_bytes()); records[id as usize][5..7].copy_from_slice(&index.to_be_bytes()); } let xref_offset = pdf.len(); records[xref_id as usize][0] = 1; records[xref_id as usize][1..5].copy_from_slice(&(xref_offset as u32).to_be_bytes()); pdf.extend_from_slice( format!("{xref_id} 0 obj\n<< /Type /XRef /Size {} /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", records.len(), records.len() * 7) .as_bytes(), ); for record in records { pdf.extend_from_slice(&record); } pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); pdf } // Stream 2's /Length starts a chain of `hops` indirect /Length lookups, each naming a new object; only the last // link's /Length is direct. Compressed: link k is the integer 3 + 2k (the length it measures) alone in the object // stream 4 + 2k, whose own /Length is link k + 1. Plain: link k is the stream 3 + k, whose /Length is link k + 1. fn length_chain_pdf(hops: u32, compressed: bool) -> Vec { let length = |hop: u32, next: u32, data: &str| if hop + 1 < hops { format!("{next} 0 R") } else { data.len().to_string() }; let (mut containers, mut objects) = (Vec::new(), Vec::new()); let mut value = 5; // the length of stream 2's data, "hello" for hop in 0..hops { if compressed { let (id, container) = (3 + 2 * hop, 4 + 2 * hop); let index = format!("{id} 0 "); let data = format!("{index}{value}"); let dict = format!("/Type /ObjStm /N 1 /First {} /Length {}", index.len(), length(hop, id + 2, &data)); objects.push((container, format!("<< {dict} >>\nstream\n{data}\nendstream"))); containers.push((id, container, 0)); value = data.len(); } else { objects.push((3 + hop, format!("<< /Length {} >>\nstream\n\nendstream", length(hop, 4 + hop, "")))); } } let objects: Vec<_> = objects.iter().map(|(id, body)| (*id, body.as_bytes())).collect(); compressed_length_pdf(&containers, &objects) } // Lenient loading keeps a document whose stream 2 has an unresolvable /Length: the catalog, and stream 2's // dictionary without its data. fn load_without_stream_2_data(pdf: &[u8]) -> Document { let doc = Document::load_mem(pdf).unwrap(); assert!(doc.catalog().unwrap().has_type(b"Catalog")); assert!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content.is_empty()); doc } #[test] fn compressed_object_self_container_does_not_recurse() { let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 3, 0)], &[])); assert!(doc.get_object((3, 0)).is_err()); } #[test] fn compressed_object_mutual_containers_do_not_recurse() { let doc = load_without_stream_2_data(&compressed_length_pdf(&[(3, 4, 0), (4, 3, 0)], &[])); assert!(doc.get_object((3, 0)).is_err()); assert!(doc.get_object((4, 0)).is_err()); } #[test] fn object_stream_length_cycle_does_not_recurse() { let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 3 0 R >>\nstream\n3 0 5\nendstream")]); let doc = load_without_stream_2_data(&pdf); // The container's /Length is the object it holds: the container is kept without data, so 3 never loads. assert!(doc.get_object((4, 0)).unwrap().as_stream().unwrap().content.is_empty()); assert!(doc.get_object((3, 0)).is_err()); } #[test] fn compressed_object_container_chain_does_not_recurse() { let containers: Vec<_> = (3..100_003).map(|id| (id, id + 1, 0)).collect(); let pdf = compressed_length_pdf(&containers, &[(100_003, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 >>\nstream\n3 0 5\nendstream")]); let doc = load_without_stream_2_data(&pdf); // The object stream in the file loads, but the xref puts 3 in the compressed container 4, which is refused. assert_eq!(doc.get_object((100_003, 0)).unwrap().as_stream().unwrap().content, b"3 0 5"); assert!(doc.get_object((3, 0)).is_err()); } #[test] fn compressed_objects_and_indirect_stream_length_still_load() { let pdf = compressed_length_pdf( &[(3, 4, 0), (5, 4, 1)], &[(4, b"<< /Type /ObjStm /N 2 /First 8 /Length 17 >>\nstream\n3 0 5 2 5 (value)\nendstream")], ); let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); assert_eq!(doc.get_object((5, 0)).unwrap().as_str().unwrap(), b"value"); assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); } #[test] fn compressed_length_in_a_container_with_an_indirect_length_still_loads() { // Two hops, as in a valid file: stream 2's /Length (3) is compressed in container 4, whose own /Length (5) is // a plain indirect integer. let pdf = compressed_length_pdf(&[(3, 4, 0)], &[(4, b"<< /Type /ObjStm /N 1 /First 4 /Length 5 0 R >>\nstream\n3 0 5\nendstream"), (5, b"5")]); let doc = Document::load_mem_with_options(&pdf, LoadOptions { strict: true, ..Default::default() }).unwrap(); assert_eq!(doc.get_object((3, 0)).unwrap().as_i64().unwrap(), 5); assert_eq!(doc.get_object((5, 0)).unwrap().as_i64().unwrap(), 5); assert_eq!(doc.get_object((2, 0)).unwrap().as_stream().unwrap().content, b"hello"); } #[test] fn compressed_length_chain_does_not_recurse() { // Every container is an ordinary object in the file and every hop names a new object, so neither the // container check nor the cycle history stops the chain; each hop recursed once more (a 512 KiB stack // overflowed within 150 hops). Lookups past the cap fail: the first link never loads, while the last one, // next to the direct /Length, does. let hops = 5_000; let doc = load_without_stream_2_data(&length_chain_pdf(hops, true)); assert!(doc.get_object((3, 0)).is_err()); assert!(doc.get_object((3 + 2 * (hops - 1), 0)).unwrap().as_i64().is_ok()); // The cap itself: loading link j reads its container's /Length, a chain of 2 * (hops - 1 - j) lookups, and // 16 are allowed, so the 9th link from the end loads and the 10th does not. assert!(doc.get_object((3 + 2 * (hops - 9), 0)).is_ok()); assert!(doc.get_object((3 + 2 * (hops - 10), 0)).is_err()); } #[test] fn plain_stream_length_chain_does_not_recurse() { // The same recursion without object streams: each stream's /Length names the next stream (a 512 KiB stack // overflowed within 200 hops). A stream is no length, so every link is kept as a stream without data. let hops = 5_000; let doc = load_without_stream_2_data(&length_chain_pdf(hops, false)); assert!((3..3 + hops).all(|id| doc.get_object((id, 0)).is_ok_and(|link| link.as_stream().is_ok_and(|s| s.content.is_empty())))); } fn indexed_object_stream(pairs: &[(u32, usize)], members: &[u8]) -> lopdf::Stream { let mut content = Vec::new(); for &(id, offset) in pairs { content.extend_from_slice(format!("{id} {offset} ").as_bytes()); } let mut dict = Dictionary::new(); dict.set("Type", "ObjStm"); dict.set("N", pairs.len() as i64); dict.set("First", content.len() as i64); content.extend_from_slice(members); lopdf::Stream::new(dict, content) } #[test] fn object_stream_member_extents_bound_owned_strings() { let mut members = vec![b'('; 32]; members.extend(vec![b'x'; 1 << 20]); members.extend([b')'; 32]); // Put the innermost (complete) string first in index order, then overlapping starts // and duplicates. Only this first member at offset 31 may own the large string. let mut pairs = vec![(100, 31)]; pairs.extend((0..31).map(|offset| (offset as u32 + 1, offset))); pairs.extend([(200, 31), (201, 31)]); let stream = indexed_object_stream(&pairs, &members); let parsed = lopdf::ObjectStream::new_with_limit(&stream, Some(2 << 20)).unwrap(); assert_eq!(parsed.objects.get(&(100, 0)).unwrap().as_str().unwrap().len(), 1 << 20); let total: usize = parsed.objects.values().filter_map(|object| object.as_str().ok()).map(<[u8]>::len).sum(); assert!(total <= stream.content.len(), "owned {total} string bytes from {} decoded bytes", stream.content.len()); assert!(!parsed.objects.contains_key(&(200, 0))); assert!(!parsed.objects.contains_key(&(201, 0))); } #[test] fn object_stream_member_extents_preserve_index_order_and_whitespace() { let members = b" \n42\n /Name \n[true 7]\n << /Key (value) >>\n (last)"; // Unsorted offsets, and object 9 appears twice: its last index entry still wins. let pairs = [(9, 0), (12, 21), (10, 5), (11, 12), (9, 41)]; let parsed = lopdf::ObjectStream::new(&indexed_object_stream(&pairs, members)).unwrap(); assert_eq!(parsed.objects.len(), 4); assert_eq!(parsed.objects.get(&(9, 0)).unwrap().as_str().unwrap(), b"last"); assert_eq!(parsed.objects.get(&(10, 0)).unwrap().as_name().unwrap(), b"Name"); assert_eq!(parsed.objects.get(&(11, 0)).unwrap().as_array().unwrap(), &[Object::Boolean(true), Object::Integer(7)]); assert_eq!(parsed.objects.get(&(12, 0)).unwrap().as_dict().unwrap().get(b"Key").unwrap().as_str().unwrap(), b"value"); } #[test] fn view_and_user_space_round_trip_for_every_rotation() { for rotation in [0u16, 90, 180, 270] { let (w, h) = if rotation % 180 == 0 { (200.0, 300.0) } else { (300.0, 200.0) }; let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }; for (x, y) in [(0.0, 0.0), (15.0, 40.0), (w, h)] { let [ux, uy] = p.view_to_user(x, y); let [vx, vy] = p.user_to_view(ux, uy); assert!((vx - x).abs() < 1e-3 && (vy - y).abs() < 1e-3, "{rotation}: {x},{y} → {ux},{uy} → {vx},{vy}"); } } // Unrotated: the view's top-left is the crop box's top-left. let p = super::PageInfo { width: 200.0, height: 300.0, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 0 }; assert_eq!(p.view_to_user(0.0, 0.0), [10.0, 320.0]); assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); } /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. #[test] fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { let mut note = super::Annotation { page: 0, subtype: "Text".into(), author: None, contents: None, modified: None, name: None, in_reply_to: None, rect: [40.0, 50.0, 70.0, 70.0], color: None, index: 0, state: None, quads: Vec::new(), locked: false, intent: None, fill_sign: false, no_rotate: true, points: Vec::new(), }; let rect = note.rect.map(f64::from); for rotation in [0u16, 90, 180, 270, 45] { let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; let shown = note.shown_rect(rotation); let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); let [cx, cy] = p.user_to_view(40.0, 70.0); let want = [cx, cy, cx + 30.0, cy + 20.0]; let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; let back = note.rect_for_shown(resized, rotation); assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); assert_eq!(note.shown(back, rotation), resized, "{rotation}"); } note.no_rotate = false; assert_eq!(note.shown_rect(90), note.rect); assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); } use super::*; #[test] fn roman_and_alpha_labels() { assert_eq!(roman(4, MAX_LABEL_BYTES).as_deref(), Some("iv")); assert_eq!(roman(1994, MAX_LABEL_BYTES).as_deref(), Some("mcmxciv")); assert_eq!(alpha(1, MAX_LABEL_BYTES).as_deref(), Some("a")); assert_eq!(alpha(27, MAX_LABEL_BYTES).as_deref(), Some("aa")); assert_eq!(alpha(53, MAX_LABEL_BYTES).as_deref(), Some("aaa")); } // Synthetic label-only inspection fixtures: never construct an explosive branching tree. fn label_info(nums: Vec, kids: Vec) -> DocInfo { let mut doc = Document::with_version("1.7"); let mut tree = Dictionary::new(); tree.set("Nums", nums); tree.set("Kids", kids); doc.objects.insert((6, 0), Object::Dictionary(tree)); let mut catalog = Dictionary::new(); catalog.set("Type", Object::Name(b"Catalog".to_vec())); catalog.set("PageLabels", Object::Reference((6, 0))); doc.objects.insert((1, 0), Object::Dictionary(catalog)); doc.trailer.set("Root", Object::Reference((1, 0))); let mut info = DocInfo { pages: (1..=3) .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) .collect(), ..Default::default() }; Inspector::new(&doc).fill(&mut info); info } fn label_spec(style: &str, prefix: Vec, first: i64) -> Object { let mut d = Dictionary::new(); d.set("S", Object::Name(style.as_bytes().to_vec())); d.set("P", Object::String(prefix, lopdf::StringFormat::Hexadecimal)); d.set("St", first); Object::Dictionary(d) } fn assert_label_fallback(info: &DocInfo) { assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["1", "2", "3"]); assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); assert!(info.warnings[0].contains("Page labels"), "{:?}", info.warnings); assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); } // Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata. fn page_prefix_space_pdf(prefix: Vec, decimal: bool, indirect: bool) -> Vec { use lopdf::dictionary; let mut doc = Document::with_version("1.7"); let pages_id = doc.new_object_id(); let kids: Vec = (0..3) .map(|_| { doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], "Resources" => Dictionary::new(), }) .into() }) .collect(); doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into()); let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal); let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix }; let mut label = Dictionary::new(); label.set("P", prefix); if decimal { label.set("S", Object::Name(b"D".to_vec())); } let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] }; let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels }); let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") }); doc.trailer.set("Root", catalog_id); doc.trailer.set("Info", info_id); let mut bytes = Vec::new(); doc.save_to(&mut bytes).expect("write original page-prefix fixture"); bytes } #[test] fn page_label_prefix_spaces_survive_public_inspection() { let mut utf16 = vec![0xfe, 0xff]; utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes)); for (raw, prefix, decimal, indirect) in [ (b" leading".to_vec(), " leading", true, false), (b"trailing ".to_vec(), "trailing ", true, false), (b" both ".to_vec(), " both ", false, false), (b" ".to_vec(), " ", false, false), (b" Caf\xe9 ".to_vec(), " Café ", true, false), (utf16.clone(), " Ω ", true, false), (utf16, " Ω ", false, true), (Vec::new(), "", true, false), (Vec::new(), "", false, false), ] { let bytes = page_prefix_space_pdf(raw, decimal, indirect); let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture"); let expected: Vec = (1..=3) .map(|n| { if decimal { format!("{prefix}{n}") } else if prefix.is_empty() { n.to_string() } else { prefix.to_owned() } }) .collect(); assert_eq!( info.pages.iter().map(|p| p.label.clone()).collect::>(), expected, "{prefix:?}, decimal={decimal}, indirect={indirect}" ); assert!(info.warnings.is_empty(), "{:?}", info.warnings); assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization"); } } #[test] fn page_label_limits_keep_normal_pdf_numbering() { for (style, first, expected) in [ ("r", 4, ["iv", "v", "vi"]), ("R", 1994, ["MCMXCIV", "MCMXCV", "MCMXCVI"]), ("a", 26, ["z", "aa", "bb"]), ("A", 52, ["ZZ", "AAA", "BBB"]), ] { let info = label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new()); assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), expected); assert!(info.warnings.is_empty(), "{:?}", info.warnings); } } #[test] fn page_label_limits_reject_long_prefixes_without_partial_labels() { for prefix in [vec![b'x'; 1025], vec![b'x'; 2051]] { let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("D", prefix, 1)]; assert_label_fallback(&label_info(nums, Vec::new())); } } #[test] fn page_label_limits_count_decoded_utf8_bytes() { let mut prefix = vec![0xfe, 0xff]; for _ in 0..342 { prefix.extend_from_slice(&0x2022u16.to_be_bytes()); } assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec("", prefix, 1)], Vec::new())); } #[test] fn page_label_limits_bound_roman_and_alpha_output() { // Just 1025 output bytes on the old path, so RED does not consume excessive memory. for (style, first) in [("r", 1_025_000), ("a", 26 * 1024 + 1)] { assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), first)], Vec::new())); } } #[test] fn page_label_limits_detect_one_node_cycle() { let nums = vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]; assert_label_fallback(&label_info(nums, vec![Object::Reference((6, 0))])); } #[test] fn page_label_limits_accept_utf16_ascii_at_the_output_boundary() { let mut prefix = vec![0xfe, 0xff]; for _ in 0..1023 { prefix.extend_from_slice(&u16::from(b'x').to_be_bytes()); } let info = label_info(vec![Object::Integer(0), label_spec("D", prefix, 1)], Vec::new()); assert!(info.warnings.is_empty(), "{:?}", info.warnings); assert_eq!(info.pages[0].label, format!("{}1", "x".repeat(1023))); assert!(info.pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); } #[test] fn page_label_limits_refuse_hostile_numbers_before_allocating() { for style in ["r", "R", "a", "A"] { assert_label_fallback(&label_info(vec![Object::Integer(0), label_spec(style, Vec::new(), i64::MAX)], Vec::new())); } // Decimal labels remain small even when their first number exceeds 32-bit usize. let info = label_info(vec![Object::Integer(0), label_spec("D", Vec::new(), i64::MAX)], Vec::new()); assert_eq!(info.pages[0].label, i64::MAX.to_string()); assert_eq!(info.pages[1].label, (i64::MAX as u64 + 1).to_string()); assert!(info.warnings.is_empty()); } #[test] fn page_label_limits_bound_wide_number_trees() { assert_label_fallback(&label_info(Vec::new(), vec![Object::Null; MAX_LABEL_TREE_WORK + 1])); let pair = [Object::Integer(0), label_spec("D", Vec::new(), 1)]; let nums = (0..MAX_LABEL_TREE_WORK).flat_map(|_| pair.clone()).collect(); assert_label_fallback(&label_info(nums, Vec::new())); } #[test] fn page_label_limits_bound_total_custom_output_atomically() { let doc = Document::with_version("1.7"); let mut tree = Dictionary::new(); tree.set("Nums", vec![Object::Integer(0), label_spec("", vec![b'x'; MAX_LABEL_BYTES], 1)]); let mut catalog = Dictionary::new(); catalog.set("PageLabels", Object::Dictionary(tree)); let count = MAX_LABEL_TOTAL_BYTES / MAX_LABEL_BYTES + 1; let mut pages: Vec<_> = (1..=count) .map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }) .collect(); let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); assert!(err.contains("4 MiB"), "{err}"); assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); // Exact aggregate boundary remains supported. pages.pop(); Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); } #[test] fn page_label_limits_bound_the_total_of_decoded_prefixes() { // `ranges` ranges naming one shared prefix object of `prefix` bytes, without a style. let shared = |ranges: i64, prefix: usize| { let mut doc = Document::with_version("1.7"); doc.objects.insert((7, 0), Object::String(vec![b'x'; prefix], lopdf::StringFormat::Literal)); let mut spec = Dictionary::new(); spec.set("P", Object::Reference((7, 0))); doc.objects.insert((8, 0), Object::Dictionary(spec)); let mut tree = Dictionary::new(); tree.set("Nums", (0..ranges).flat_map(|i| [Object::Integer(i), Object::Reference((8, 0))]).collect::>()); let mut catalog = Dictionary::new(); catalog.set("PageLabels", Object::Dictionary(tree)); (doc, catalog) }; let mut pages: Vec<_> = (1..=3).map(|n| PageInfo { width: 200.0, height: 300.0, crop: [0.0, 0.0, 200.0, 300.0], rotation: 0, label: n.to_string() }).collect(); // Each range decodes its prefix, so a small file sharing one prefix object asks for a // copy per range: 5,000 ranges and a 1,000-byte prefix make 5 MB, past the 4 MiB total. // Three pages make three labels, far below the label total: the prefixes alone refuse it. let (doc, catalog) = shared(5000, 1000); let err = Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap_err(); assert!(err.contains("prefixes exceed 4 MiB"), "{err}"); assert!(pages.iter().enumerate().all(|(i, p)| p.label == (i + 1).to_string())); // Exactly 4 MiB of prefixes is still read. let (doc, catalog) = shared((MAX_PREFIX_TOTAL_BYTES / MAX_LABEL_BYTES) as i64, MAX_LABEL_BYTES); Inspector::new(&doc).page_labels(&catalog, &mut pages).unwrap(); assert!(pages.iter().all(|p| p.label.len() == MAX_LABEL_BYTES)); } #[test] fn page_label_limits_handle_prefix_only_and_reference_cycles() { let info = label_info(vec![Object::Integer(0), label_spec("", b"prefix".to_vec(), i64::MAX)], Vec::new()); assert!(info.warnings.is_empty()); assert!(info.pages.iter().all(|p| p.label == "prefix")); let mut doc = Document::with_version("1.7"); doc.objects.insert((6, 0), Object::Reference((6, 0))); let mut catalog = Dictionary::new(); catalog.set("PageLabels", Object::Reference((6, 0))); assert!(Inspector::new(&doc).page_labels(&catalog, &mut []).unwrap_err().contains("cyclic")); } #[test] fn page_label_limits_bound_depth_and_count_combined_work() { for depth in [MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_DEPTH + 1] { let mut child = Dictionary::new(); child.set("Nums", vec![Object::Integer(0), label_spec("D", b"custom-".to_vec(), 1)]); let mut child = Object::Dictionary(child); for _ in 1..depth { let mut parent = Dictionary::new(); parent.set("Kids", vec![child]); child = Object::Dictionary(parent); } let info = label_info(Vec::new(), vec![child]); if depth == MAX_LABEL_TREE_DEPTH { assert!(info.warnings.is_empty()); assert_eq!(info.pages[0].label, "custom-1"); } else { assert_label_fallback(&info); } } // Each array alone fits: their combined work still must be bounded. let half = MAX_LABEL_TREE_WORK / 2; let spec = label_spec("D", Vec::new(), 1); let nums = (0..half).flat_map(|i| [Object::Integer(i as i64), spec.clone()]).collect(); assert_label_fallback(&label_info(nums, vec![Object::Null; half])); } #[test] fn page_label_limits_keep_the_later_of_duplicate_ranges() { let nums = vec![Object::Integer(0), label_spec("D", b"first-".to_vec(), 1), Object::Integer(0), label_spec("D", b"last-".to_vec(), 1)]; let info = label_info(nums, Vec::new()); assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["last-1", "last-2", "last-3"]); assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); assert!(info.warnings[0].contains("twice"), "{:?}", info.warnings); } #[test] fn page_label_limits_keep_other_ranges_when_one_is_too_long() { // Page 1 is labelled normally; pages 2–3 would need over 1024 bytes of roman numerals. let nums = vec![Object::Integer(0), label_spec("D", b"ok-".to_vec(), 1), Object::Integer(1), label_spec("r", Vec::new(), 1_025_000)]; let info = label_info(nums, Vec::new()); assert_eq!(info.pages.iter().map(|p| p.label.as_str()).collect::>(), ["ok-1", "2", "3"]); assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings); assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings); } const ATTACHMENTS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 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] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj 4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj 5 0 obj << /Type /EmbeddedFile /Length 11 >> stream hello notes endstream endobj 6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj 7 0 obj << /Type /EmbeddedFile /Length 5 >> stream a,b,c endstream endobj 8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj 9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn attachments_of_both_kinds_are_listed_and_extracted() { let bytes = Arc::new(ATTACHMENTS.to_vec()); let info = inspect(bytes.clone(), None).expect("opens"); let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect(); assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}"); for a in &info.attachments { let data = attachment_data(&bytes, None, a).expect("extracts"); let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; assert_eq!(data, expected, "{}", a.name); } // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 // metrics are exact, so the substitution is named rather than left implicit. assert_eq!( info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None, substitute: Some("Helvetica".into()), substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), }] ); } const LAYERS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R] [7 0 R] 8 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] /Annots [10 0 R 11 0 R] >> endobj 5 0 obj << /Type /OCG /Name (Red) >> endobj 6 0 obj << /Type /OCG /Name (Green) >> endobj 7 0 obj << /Type /OCG /Name (Blue) >> endobj 8 0 obj [6 0 R 7 0 R] endobj 9 0 obj [/OFF 5 0 R] endobj 10 0 obj << /Type /Annot /Subtype /Link /Rect [10 10 50 30] /A << /S /SetOCGState /State [7 0 R /ON 6 0 R /Bogus 5 0 R /Toggle 5 0 R 1 7 0 R] /PreserveRB false >> >> endobj 11 0 obj << /Type /Annot /Subtype /Link /Rect [60 10 100 30] /A << /S /SetOCGState /State 9 0 R >> >> endobj trailer << /Root 1 0 R >> %%EOF"; /// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page /// leaves out, lenient about what is out of range, and only for pages that have either. const TRANSITIONS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj 4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj 5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj 6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj 7 0 obj << /Type /Page /Parent 2 0 R >> endobj 8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn page_transitions_and_display_times_are_read() { use super::TransitionStyle as S; let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens"); let t = &info.transitions; assert_eq!(t.iter().map(|t| t.page).collect::>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither"); assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0))); assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]); assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans"); assert_eq!( (t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal), (Some(S::Fly), false, 0.5, true, None, false), "an indirect /Trans: {:?}", t[2] ); // Out of range: an unknown style is the spec's default R, a non-numeric /D the default // second, a direction that is not one of the five falls back to 0, a bad /Dm to // horizontal, and a negative /Dur is no display time. assert_eq!( (t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display), (Some(S::Replace), 1.0, Some(0), true, None), "{:?}", t[3] ); } const CUSTOM_INFO: &[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 /MediaBox [0 0 200 200] >> endobj 3 0 obj << /Type /Page /Parent 2 0 R >> endobj 4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj trailer << /Root 1 0 R /Info 4 0 R >> %%EOF"; #[test] fn custom_document_properties_are_listed_apart_from_the_standard_ones() { let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); let mut custom = info.custom.clone(); custom.sort(); // Revision is a number, not text, so it is not listed; Trapped is a standard entry. assert_eq!( custom, [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] ); } #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); let layers: Vec<_> = info.layers.iter().map(|l| (l.id, l.name.as_str(), l.visible)).collect(); assert_eq!(layers, [((5, 0), "Red", true), ((6, 0), "Green", false), ((7, 0), "Blue", true)]); // A group of one constrains nothing; an indirect group is read. assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); } #[test] fn initial_layer_visibility_is_the_default_configuration() { // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue // isn't listed, so it takes the base state. let pdf = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 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] >> endobj 5 0 obj << /Type /OCG /Name (Red) >> endobj 6 0 obj << /Type /OCG /Name (Green) >> endobj 7 0 obj << /Type /OCG /Name (Blue) >> endobj trailer << /Root 1 0 R >> %%EOF"; let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); } const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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] /Annots [5 0 R 6 0 R] >> endobj 4 0 obj << /Fields [] >> endobj 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Rect [10 100 90 120] >> endobj 6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 60 90 80] >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn fields_listed_only_on_the_pages_are_listed() { let info = inspect(Arc::new(ORPHAN_FIELDS.to_vec()), None).expect("opens"); let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); } /// A `Structure` that counts the objects read through it. struct CountingReads<'a> { doc: &'a Document, reads: std::cell::Cell, } impl Structure for CountingReads<'_> { fn trailer(&self) -> &Dictionary { &self.doc.trailer } fn get_object(&self, id: ObjectId) -> lopdf::Result<&Object> { self.reads.set(self.reads.get() + 1); self.doc.get_object(id) } fn encrypted(&self) -> bool { false } } /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. #[test] fn repeated_annotations_are_listed_once_within_a_byte_budget() { let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ 5 0 obj [{} {small}] endobj\n\ 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", "6 0 R ".repeat(1000), "x".repeat(64 << 10), ); let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; Inspector::new(&doc).fill(&mut info); let notes = &info.annotations; assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); assert!(notes.len() > 1); assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); } /// A link reached through an indirect object that holds only a reference to it is the same /// annotation as the link itself, and so is a page array reached the same way: each is listed once. #[test] fn an_annotation_reached_through_a_reference_chain_is_listed_once() { let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\ 5 0 obj 6 0 R endobj\n\ 6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\ 7 0 obj [5 0 R 6 0 R] endobj\n\ 8 0 obj 7 0 R endobj\n\ trailer << /Root 1 0 R >>\n%%EOF"; let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let mut saved = Vec::new(); doc.save_to(&mut saved).expect("saves"); let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; for reader in [&doc as &dyn Structure, &lazy] { let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; Inspector::new(reader).fill(&mut info); assert_eq!(info.links.len(), 1, "{:?}", info.links); } } /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw /// length, so a string many entries share is converted only a few times. #[test] fn rejected_text_conversions_spend_the_budget() { let mut budget = 10; let mut conversions = 0; for _ in 0..10 { // Each 0xFF becomes the three-byte replacement character. let kept = spend(&[0xFF; 5], &mut budget, |b| { conversions += 1; String::from_utf8_lossy(b).into_owned() }); assert_eq!(kept, None); } assert_eq!((conversions, budget), (2, 0)); let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF"; let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]); let mut budget = 10; assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None); assert_eq!(budget, 7); // Text that trims short spends its raw length. let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]); let mut budget = 10; assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x")); assert_eq!(budget, 3); } /// An annotation attachment's `/Contents` spends the text budget only when it names the /// attachment, so long descriptions of named files leave a later web address listed. #[test] fn a_named_attachment_leaves_its_contents_unspent() { let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20); // Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget. let uri = format!("https://example.com/{}", "a".repeat(2 << 20)); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \ << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\ 6 0 obj ({}) endobj\n\ trailer << /Root 1 0 R >>\n%%EOF", "x".repeat(1 << 20), ); let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; let mut info = DocInfo { pages: vec![page], ..Default::default() }; Inspector::new(&doc).fill(&mut info); assert!(info.attachments.iter().all(|a| a.name == "a.txt")); assert_eq!(info.links.len(), 1); assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); } /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. #[test] fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() { let entries = "4 0 R << /Type /Filespec >> ".repeat(20); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\ 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ 4 0 obj ({}) endobj\n\ trailer << /Root 1 0 R >>\n%%EOF", "x".repeat(1 << 20), ); let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let mut info = DocInfo::default(); Inspector::new(&doc).fill(&mut info); let kept: usize = info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum(); assert!(!info.attachments.is_empty()); // Past the budget an attachment still has the generic name. let generic = info.attachments.len() * "attachment".len(); assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept"); } /// An embedded file the listing keeps is extracted, though earlier keys the listing could not /// keep would fill a fresh key budget before it. #[test] fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() { let skipped = "6 0 R 7 0 R ".repeat(9); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\ 6 0 obj ({}) endobj\n\ 7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\ 8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\ 9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF", "u".repeat(15 << 20), "k".repeat(2 << 20), ); let bytes = Arc::new(pdf.into_bytes()); let info = inspect(bytes.clone(), None).expect("opens"); let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed"); assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..])); } /// Links and attachments sharing one long string each keep a copy only within /// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed. #[test] fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() { const EACH: usize = 20; let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH); let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\ 5 0 obj << /S /URI /URI 6 0 R >> endobj\n\ 6 0 obj ({}) endobj\n\ 7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF", "x".repeat(1 << 20), ); let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; let mut info = DocInfo { pages: vec![page], ..Default::default() }; Inspector::new(&doc).fill(&mut info); assert_eq!(info.links.len(), EACH); assert_eq!(info.attachments.len(), EACH); let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 }); let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len)); let kept: usize = uris.chain(descriptions).sum(); assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept"); assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI"))); } /// Links and attachments after the comment budget fills are still listed, and a JavaScript link /// among them still marks the document. #[test] fn links_and_attachments_past_the_comment_budget_are_listed() { let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", "x".repeat(64 << 10), ); let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; Inspector::new(&doc).fill(&mut info); assert!(info.annotations.len() < 1100, "the comment budget filled"); assert_eq!(info.links.len(), 1); assert!(info.has_javascript); assert_eq!(info.attachments.len(), 1); } /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. #[test] fn widgets_without_a_page_entry_are_placed_in_one_pass() { const PAGES: usize = 100; const PER_PAGE: usize = 10; let field = |p: usize, k: usize| 4 + PAGES + p * PER_PAGE + k; let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >> endobj\n"); let kids: Vec = (0..PAGES).map(|p| format!("{} 0 R", 4 + p)).collect(); pdf += &format!("2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} >> endobj\n", kids.join(" ")); let fields: Vec = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| format!("{} 0 R", field(p, k)))).collect(); pdf += &format!("3 0 obj << /Fields [{}] >> endobj\n", fields.join(" ")); for p in 0..PAGES { let mut annots: Vec = (0..PER_PAGE).map(|k| format!("{} 0 R", field(p, k))).collect(); // The last page lists the first page's first widget too. if p == PAGES - 1 { annots.push(format!("{} 0 R", field(0, 0))); } pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >> endobj\n", 4 + p, annots.join(" ")); } for p in 0..PAGES { for k in 0..PER_PAGE { pdf += &format!("{} 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (f{p}_{k}) /Rect [10 10 90 30] >> endobj\n", field(p, k)); } } pdf += "trailer << /Root 1 0 R >>\n%%EOF"; let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); let counting = CountingReads { doc: &doc, reads: std::cell::Cell::new(0) }; let mut info = DocInfo { pages: (0..PAGES) .map(|_| PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }) .collect(), ..Default::default() }; Inspector::new(&counting).fill(&mut info); let placed: Vec<_> = info.fields.iter().map(|f| (f.name.clone(), f.page)).collect(); let want: Vec<_> = (0..PAGES).flat_map(|p| (0..PER_PAGE).map(move |k| (format!("f{p}_{k}"), Some(p)))).collect(); assert_eq!(placed, want); // A handful of reads per page and field; searching the pages for each field read the // page dictionaries about 50,000 times. let reads = counting.reads.get(); assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); } /// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its /// cross-reference data, and the first's only trailer is a cross-reference stream. The /// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer` /// keyword and failed, so Document Properties showed every field empty. The rebuild now /// takes the cross-reference stream's dictionary as the trailer. #[test] fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() { let header = "%PDF-1.5\n"; let obj1 = "1 0 obj<>endobj\n"; let obj2 = "2 0 obj<>endobj\n"; let obj3 = "3 0 obj<>endobj\n"; let obj4 = "4 0 obj<>endobj\n"; let o1 = header.len(); let o2 = o1 + obj1.len(); let o3 = o2 + obj2.len(); let o4 = o3 + obj3.len(); let o5 = o4 + obj4.len(); let rows: Vec = [[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]] .concat(); let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes(); bytes.extend_from_slice(format!("5 0 obj<>stream\n", rows.len()).as_bytes()); bytes.extend_from_slice(&rows); // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n"); bytes.extend_from_slice(&[b'x'; 1024]); let info = inspect(Arc::new(bytes), None).unwrap(); assert_eq!(info.pages.len(), 1); assert_eq!(info.title.as_deref(), Some("Kept")); assert_eq!(info.creator.as_deref(), Some("Me")); } /// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later /// cross-reference stream whose /Root points at the Info dictionary used to replace the /// earlier `trailer`, so Document Properties lost the catalog it should have read. #[test] fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() { let mut bytes = b"%PDF-1.5\n".to_vec(); for body in [ "<>", "<>", "<>", "<>", ] { let n = bytes.iter().filter(|b| **b == b'\n').count(); bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes()); } bytes.extend_from_slice(b"trailer\n<>\n"); bytes.extend_from_slice(b"5 0 obj<>stream\n\x01\x00\x09\x00\nendstream\nendobj\n"); // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n"); bytes.extend_from_slice(&[b'x'; 1024]); let info = inspect(Arc::new(bytes.clone()), None).unwrap(); assert_eq!(info.pages.len(), 1); assert_eq!(info.title.as_deref(), Some("Kept")); // A later stream whose /Root names the catalog's number with another generation does // not count either: loading `1 1 R` finds no such object. let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap(); bytes[at..at + 10].copy_from_slice(b"/Root 1 1 "); let info = inspect(Arc::new(bytes), None).unwrap(); assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept"))); } #[test] fn lazy_structure_matches_the_compatibility_inspector() { // Each fixture as lopdf writes it, and with its objects in object streams. let packed = |bytes: &[u8]| { let doc = pdfcraft_cos::Document::open(Arc::new(bytes.to_vec())).unwrap(); pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions { object_streams: true, ..Default::default() }).unwrap() }; let fixtures = [ATTACHMENTS, LAYERS, DESTS].into_iter().flat_map(|bytes| { let mut original = Document::load_mem(bytes).unwrap(); let mut plain = Vec::new(); original.save_to(&mut plain).unwrap(); let stm = packed(&plain); [plain, stm] }); for bytes in fixtures { let lazy = LazyStructure::new(Arc::new(bytes.clone()), None).unwrap(); let eager = Document::load_mem(&bytes).unwrap(); let mut a = inspect(Arc::new(bytes), None).unwrap(); let mut b = DocInfo { pages: a.pages.clone(), ..Default::default() }; let mut c = DocInfo { pages: a.pages.clone(), ..Default::default() }; Inspector::new(&lazy).fill(&mut b); Inspector::new(&eager).fill(&mut c); assert!(!lazy.failed()); assert_eq!(format!("{b:?}"), format!("{c:?}")); a.file_size = 0; a.pdf_version.clear(); assert_eq!(format!("{a:?}"), format!("{c:?}")); } } #[test] fn links_read_set_layer_actions() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); use LayerOp::{Off, On, Toggle}; assert_eq!( targets, [ // Groups before the first name or after an unknown one are skipped, and so is // anything that isn't a group. LinkTarget::SetLayers { changes: vec![(On, (6, 0)), (Toggle, (5, 0)), (Toggle, (7, 0))], preserve_rb: false }, // An indirect /State; /PreserveRB defaults to true. LinkTarget::SetLayers { changes: vec![(Off, (5, 0))], preserve_rb: true }, ] ); } /// #4168: a Launch action naming a `.pdf` file is read as the link to that file it amounts /// to, `\` separators included, and a stray `/D` (which Launch has no use for) doesn't stop /// it. One naming anything else, with no file, or with `/Win` launch parameters stays an /// action that isn't followed. #[test] fn launch_actions_that_name_a_pdf_open_it_like_a_remote_link() { let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R] >> endobj\n\ 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /Launch /F (..\\\\se\\\\se_ST.pdf) >> >> endobj\n\ 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /Launch /F << /Type /Filespec /F (chapters/Two.PDF) >> /NewWindow true >> >> endobj\n\ 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /Launch /F (tool.exe) >> >> endobj\n\ 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /Launch /F (a.pdf) /Win << /F (cmd.exe) /P (/c x) >> >> >> endobj\n\ 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /Launch /Win << /F (b.pdf) >> >> >> endobj\n\ 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /Launch /F (.pdf) >> >> endobj\n\ 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /Launch /F (dir/a\\\\b.pdf) >> >> endobj\n\ 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /Launch /FS /URL /F (https://example.com/x.pdf) >> >> endobj\n\ 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /Launch /F (c.pdf) /D 0 >> >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF"; let info = inspect(Arc::new(pdf.as_bytes().to_vec()), None).expect("opens"); let targets: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); let launch = |file: &str, new_window| LinkTarget::RemoteGoTo { launch: true, file: file.into(), destination: RemoteDestination::Default, new_window }; let other = || LinkTarget::Other("Launch".into()); assert_eq!( targets, [ launch("../se/se_ST.pdf", None), launch("chapters/Two.PDF", Some(true)), other(), other(), other(), other(), launch("dir/a\\b.pdf", None), other(), launch("c.pdf", None), ] ); } #[test] fn links_read_bounded_local_remote_go_to_actions() { use DestView::{Fit, Xyz}; let too_long = "a".repeat(MAX_REMOTE_FILE_SPEC_BYTES + 1); let wide_file = format!("", "4E00".repeat(1400)); let wide_name = format!("", "4E00".repeat(342)); let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n\ 3 0 obj << /Type /Page /Parent 2 0 R /Annots [99 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R] >> endobj\n\ 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /GoToR /F (chapters/remote.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >> >> endobj\n\ 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoToR /F << /Type /Filespec /F (fallback.pdf) /UF >> /D [0 /Fit] /NewWindow false >> >> endobj\n\ 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /A << /S /GoToR /F (remote.pdf) >> >> endobj\n\ 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /A << /S /GoToR /F (chapter.pdf) /D (chapter) >> >> endobj\n\ 34 0 obj << /Type /Annot /Subtype /Link /Rect [0 80 10 90] /A << /S /GoToR /FS /URL /F (https://example.com) /D [0 /Fit] >> >> endobj\n\ 35 0 obj << /Type /Annot /Subtype /Link /Rect [0 100 10 110] /A << /S /GoToR /F ({too_long}) /D [0 /Fit] >> >> endobj\n\ 36 0 obj << /Type /Annot /Subtype /Link /Rect [0 120 10 130] /A << /S /GoToR /F (chapter.pdf) /D /chapter >> >> endobj\n\ 37 0 obj << /Type /Annot /Subtype /Link /Rect [0 140 10 150] /A << /S /GoToR /F (chapter.pdf) /D [-1 /Fit] >> >> endobj\n\ 38 0 obj << /Type /Annot /Subtype /Link /Rect [0 160 10 170] /A << /S /GoToR /F (chapter.pdf) /D [1.5 /Fit] >> >> endobj\n\ 39 0 obj << /Type /Annot /Subtype /Link /Rect [0 180 10 190] /A << /S /GoToR /F (chapter.pdf) /D [9223372036854775807 /Fit] >> >> endobj\n\ 40 0 obj << /Type /Annot /Subtype /Link /Rect [0 200 10 210] /A << /S /GoToR /F << /Type /Filespec /UF {wide_file} >> /D [0 /Fit] >> >> endobj\n\ 41 0 obj << /Type /Annot /Subtype /Link /Rect [0 220 10 230] /A << /S /GoToR /F (chapter.pdf) /D {wide_name} >> >> endobj\n\ trailer << /Root 1 0 R >>\n%%EOF" ); let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); let targets: Vec<_> = info.links.iter().map(|l| (l.index, l.target.clone())).collect(); let huge_page_target = usize::try_from(i64::MAX) .map(|page| LinkTarget::RemoteGoTo { launch: false, file: "chapter.pdf".into(), destination: RemoteDestination::Page { page, view: Fit }, new_window: None, }) .unwrap_or_else(|_| LinkTarget::Other("GoToR".into())); assert_eq!( targets, [ ( 1, LinkTarget::RemoteGoTo { launch: false, file: "chapters/remote.pdf".into(), destination: RemoteDestination::Page { page: 2, view: Xyz { left: Some(10.0), top: Some(20.0), zoom: Some(1.5) } }, new_window: Some(true), } ), ( 2, LinkTarget::RemoteGoTo { launch: false, file: "foo.pdf".into(), destination: RemoteDestination::Page { page: 0, view: Fit }, new_window: Some(false), } ), (3, LinkTarget::RemoteGoTo { launch: false, file: "remote.pdf".into(), destination: RemoteDestination::Default, new_window: None }), ( 4, LinkTarget::RemoteGoTo { launch: false, file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None } ), (5, LinkTarget::Other("GoToR".into())), (6, LinkTarget::Other("GoToR".into())), ( 7, LinkTarget::RemoteGoTo { launch: false, file: "chapter.pdf".into(), destination: RemoteDestination::Named("chapter".into()), new_window: None } ), (8, LinkTarget::Other("GoToR".into())), (9, LinkTarget::Other("GoToR".into())), (10, huge_page_target), (11, LinkTarget::Other("GoToR".into())), (12, LinkTarget::Other("GoToR".into())), ] ); } #[test] fn destination_operands_become_views() { use DestView::*; let v = |kind: &[u8], ops: &[Option]| DestView::from_operands(Some(kind), ops); // ISO 32000-2 Table 149, each type. assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }); assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }); assert_eq!(v(b"Fit", &[]), Fit); assert_eq!(v(b"FitB", &[]), Fit); assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) }); assert_eq!(v(b"FitBH", &[None]), FitH { top: None }); assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) }); assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) }); // FitR's corners are normalized. assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] }); // Missing operands are null; so are non-finite ones, and a zoom that isn't positive. assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None }); assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None }); assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None }); assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None }); assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None }); // A FitR without four usable numbers, an unknown type or no type: the top of the page. assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top); assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top); assert_eq!(v(b"Zoom", &[Some(3.0)]), Top); assert_eq!(v(b"xyz", &[Some(3.0)]), Top); assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top); } /// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form, /// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed. const DESTS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj 4 0 obj << /Type /Page /Parent 2 0 R >> endobj 10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj 11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj 12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj 13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj 14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj 15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj 16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj 17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj 18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj 19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj 20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj 30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj 31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj 32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj 33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj 40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn bookmarks_and_links_keep_their_destination_view() { use DestView::*; let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens"); let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect(); assert_eq!( outline, [ ("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }), ("fith", Some(1), FitH { top: Some(250.0) }), ("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), ("old named", Some(1), FitV { left: Some(120.0) }), ("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }), // Hostile operands fall back without failing the bookmark. ("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }), ("short", Some(1), Top), ("no type", Some(1), Top), ("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }), ("no page", None, Top), ] ); let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect(); assert_eq!( links, [ LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }), LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }), LinkTarget::Page(1, FitH { top: Some(100.0) }), LinkTarget::Page(1, Fit), ] ); } #[test] fn outline_titles_decode_every_text_string_encoding() { // Issue #142: CJK bookmark titles showed as mojibake or with a stray BOM. let pdf = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj 3 0 obj << /Type /Page /Parent 2 0 R >> endobj 10 0 obj << /Type /Outlines /First 11 0 R /Last 16 0 R /Count 6 >> endobj 11 0 obj << /Title /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >> endobj 12 0 obj << /Title /Parent 10 0 R /Next 13 0 R /Dest [3 0 R /Fit] >> endobj 13 0 obj << /Title /Parent 10 0 R /Next 14 0 R /Dest [3 0 R /Fit] >> endobj 14 0 obj << /Title /Parent 10 0 R /Next 15 0 R /Dest [3 0 R /Fit] >> endobj 15 0 obj << /Title <436166E9> /Parent 10 0 R /Next 16 0 R /Dest [3 0 R /Fit] >> endobj 16 0 obj << /Title /Parent 10 0 R /Dest [3 0 R /Fit] >> endobj trailer << /Root 1 0 R >> %%EOF"; let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); let titles: Vec<_> = info.outline.iter().map(|o| o.title.as_str()).collect(); // UTF-16BE, UTF-8 with BOM, invalid UTF-8 after a BOM (lossy, not dropped), BOM-less // raw UTF-8, PDFDocEncoding Latin-1, and a doubled BOM. assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); } #[test] fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { let text = xfa_config_dynamic_render; let config = |inner: &str| format!("{inner}"); // Designer's style: a line break before each `>`. assert_eq!( text("forbidden").as_deref(), Some("forbidden") ); assert_eq!(text(&config(" required ")).as_deref(), Some("required")); // Commented out, in CDATA, or a longer name: not the element. assert_eq!(text(&config("")), None); assert_eq!(text(&config(" b forbidden ]]>")), None); assert_eq!(text(&config("forbidden")), None); // Self-closing, empty, or holding markup: no value. assert_eq!(text(&config("")), None); assert_eq!(text(&config("")), None); assert_eq!(text(&config("forbidden")), None); // Closing tags that don't match what they close: malformed, no value (review on #3192). assert_eq!(text(&config("forbidden")), None); assert_eq!(text("forbidden"), None); // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch). let xdp = "forbiddenforbidden"; assert_eq!(text(xdp), None); // A whole XDP with the config after the template. let xdp = format!("{}", config("forbidden")); assert_eq!(text(&xdp).as_deref(), Some("forbidden")); // A whole config path inside the form data, before the real config packet, is data: the // real packet's value counts (review on #3192). let xdp = format!( "{}{}", config("forbidden"), config("required") ); assert_eq!(text(&xdp).as_deref(), Some("required")); // Only in the data, or nested in the template: no configuration at all. let xdp = format!("{}", config("forbidden")); assert_eq!(text(&xdp), None); let xdp = format!("", config("forbidden")); assert_eq!(text(&xdp), None); // acrobat7 must be a child of acrobat, not deeper. assert_eq!(text("forbidden"), None); // Cut short anywhere: no panic, nothing read. let whole = config("forbidden"); for end in 0..whole.len() { assert_eq!(text(&whole[..end]), None, "cut at {end}"); } assert_eq!(text(&whole).as_deref(), Some("forbidden")); } #[test] fn xfa_config_packets_are_read_in_utf16_too() { let xml = "forbidden"; let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect(); for bytes in [ le.clone(), be.clone(), [&[0xFF, 0xFE][..], &le].concat(), [&[0xFE, 0xFF][..], &be].concat(), [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(), ] { assert_eq!(xfa_packet_text(&bytes), xml); } // An odd byte or a broken surrogate doesn't stop the scan. assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<')); } /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were /// replaced by a layout on open, even when its configuration says it is a static form. #[test] fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() { let xfa_of = |config: &str, catalog_extra: &str| { let bodies = [ format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), "<< /Type /Page /Parent 2 0 R >>".to_string(), format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()), "<< /Length 11 >>\nstream\n