Files
pdfcraft/crates/render/src/inspect.rs
T

826 lines
35 KiB
Rust

//! Document inspection: everything panels need that is not pixels.
//!
//! Page geometry comes from hayro (which resolves inheritance and rotation); the rest comes from
//! lopdf (bootstrap, replaced by `printcraft-model` in M2). Inspection is *tolerant*: when lopdf
//! cannot load a file that hayro can render, panels are simply empty and `warnings` says why.
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 crate::OpenError;
#[derive(Clone, Debug, Default)]
pub struct DocInfo {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub pdf_version: String,
pub file_size: usize,
pub encrypted: bool,
pub tagged: bool,
pub has_javascript: bool,
pub pages: Vec<PageInfo>,
pub outline: Vec<OutlineItem>,
pub annotations: Vec<Annotation>,
pub fields: Vec<Field>,
pub links: Vec<Link>,
pub layers: Vec<Layer>,
pub fonts: Vec<FontInfo>,
pub attachments: Vec<Attachment>,
pub warnings: Vec<String>,
}
#[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,
}
/// A clickable link annotation.
#[derive(Clone, Debug)]
pub struct Link {
pub page: usize,
pub rect: [f32; 4],
pub target: LinkTarget,
}
#[derive(Clone, Debug, PartialEq)]
pub enum LinkTarget {
Page(usize),
Uri(String),
Other(String),
}
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
pub page: Option<usize>,
pub children: Vec<OutlineItem>,
pub open: bool,
}
#[derive(Clone, Debug)]
pub struct Annotation {
pub page: usize,
pub subtype: String,
pub author: Option<String>,
pub contents: Option<String>,
pub modified: Option<String>,
/// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading.
pub name: Option<String>,
pub in_reply_to: Option<String>,
/// Rect in PDF user space: [x0, y0, x1, y1].
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
}
#[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<String>,
pub page: Option<usize>,
pub tooltip: Option<String>,
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,
}
#[derive(Clone, Debug)]
pub struct Attachment {
pub name: String,
pub description: Option<String>,
pub size: Option<usize>,
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<String>,
}
/// 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<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, OpenError> {
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: 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 = LoadOptions { password: password.map(str::to_owned), ..LoadOptions::default() };
let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) {
Ok(doc) => {
let mut tmp = DocInfo::default();
std::mem::swap(&mut tmp.pages, &mut info.pages);
Inspector::new(&doc).fill(&mut tmp);
Ok(tmp)
}
Err(e) => Err(e.to_string()),
}));
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)));
}
}
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
struct Inspector<'a> {
doc: &'a Document,
page_index: HashMap<ObjectId, usize>,
}
impl<'a> Inspector<'a> {
fn new(doc: &'a Document) -> Self {
let page_index = doc.get_pages().into_iter().map(|(n, id)| (id, n as usize - 1)).collect();
Self { doc, page_index }
}
fn fill(&self, info: &mut DocInfo) {
info.encrypted = self.doc.is_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok();
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");
}
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())
.and_then(|o| o.as_bool().ok())
.unwrap_or(false);
self.page_labels(catalog, &mut info.pages);
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
let mut seen = HashSet::new();
info.outline = self.outline_siblings(first, &mut seen, 0);
}
self.annotations(info);
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())
{
let mut seen = HashSet::new();
for f in fields {
self.field(f, None, &mut info.fields, &mut seen, 0);
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
self.layers(catalog, &mut info.layers);
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) {
info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }));
}
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
}
}
// ── object helpers ──────────────────────────────────────────────────────────────────────
fn resolve(&self, o: &'a Object) -> &'a Object {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
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<String> {
let o = self.resolve(d.get(key).ok()?);
let s = match o {
Object::String(..) => lopdf::decode_text_string(o).ok()?,
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
}
fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
fn page_of(&self, o: &Object) -> Option<usize> {
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 ────────────────────────────────────────────────────────────────────────
fn dest_page(&self, dest: &Object, depth: u32) -> Option<usize> {
if depth > 8 {
return None;
}
match self.resolve(dest) {
Object::Array(a) => a.first().and_then(|p| self.page_of(p)),
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest_page(d, depth + 1)),
named @ (Object::String(..) | Object::Name(_)) => {
let key = match named {
Object::String(s, _) => s.clone(),
Object::Name(n) => n.clone(),
_ => unreachable!(),
};
self.named_dest(&key).and_then(|d| self.dest_page(d, depth + 1))
}
_ => None,
}
}
fn named_dest(&self, key: &[u8]) -> Option<&'a Object> {
let catalog = self.doc.catalog().ok()?;
if let Some(tree) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| self.dict(o)) {
let mut seen = HashSet::new();
if let Some((_, v)) = self.name_tree(tree, &mut seen, 0).into_iter().find(|(k, _)| k.as_bytes() == 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())
}
fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> 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 = lopdf::decode_text_string(self.resolve(k)).unwrap_or_default();
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));
}
}
}
out
}
// ── outline ─────────────────────────────────────────────────────────────────────────────
fn outline_siblings(&self, first: &'a Object, seen: &mut HashSet<ObjectId>, depth: u32) -> Vec<OutlineItem> {
let mut items = Vec::new();
let mut cur = Some(first);
while let Some(o) = cur {
if let Object::Reference(id) = o
&& !seen.insert(*id)
{
break; // cycle
}
let Some(d) = self.dict(o) else { break };
let page = d
.get(b"Dest")
.ok()
.and_then(|dest| self.dest_page(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_page(dest, 0)));
let children = match (d.get(b"First").ok(), depth < 32) {
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
_ => Vec::new(),
};
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, children, open });
cur = d.get(b"Next").ok();
if items.len() > 100_000 {
break;
}
}
items
}
// ── page labels (ISO 32000-2 §12.4.2) ───────────────────────────────────────────────────
fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) {
let Some(tree) = catalog.get(b"PageLabels").ok().and_then(|o| self.dict(o)) else { return };
let mut ranges: BTreeMap<usize, &Dictionary> = BTreeMap::new();
let mut stack = vec![(tree, 0u32)];
while let Some((node, depth)) = stack.pop() {
if let Ok(Object::Array(nums)) = node.get(b"Nums").map(|o| self.resolve(o)) {
for pair in nums.chunks(2) {
if let [k, v] = pair
&& let (Ok(start), Some(d)) = (self.resolve(k).as_i64(), self.dict(v))
&& start >= 0
{
ranges.insert(start as usize, d);
}
}
}
if depth < 32
&& let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o))
{
stack.extend(kids.iter().filter_map(|k| self.dict(k)).map(|d| (d, depth + 1)));
}
}
for (i, page) in pages.iter_mut().enumerate() {
let Some((&start, d)) = ranges.range(..=i).next_back() else { continue };
let first = d.get(b"St").ok().and_then(|o| o.as_i64().ok()).unwrap_or(1).max(1) as usize;
let n = first + (i - start);
let prefix = self.text(d, b"P").unwrap_or_default();
let number = match self.name(d, b"S").as_deref() {
Some("D") => n.to_string(),
Some("R") => roman(n).to_uppercase(),
Some("r") => roman(n),
Some("A") => alpha(n).to_uppercase(),
Some("a") => alpha(n),
_ => String::new(),
};
page.label = format!("{prefix}{number}");
if page.label.is_empty() {
page.label = (i + 1).to_string();
}
}
}
// ── annotations ─────────────────────────────────────────────────────────────────────────
fn annotations(&self, info: &mut DocInfo) {
for (&id, &page) in &self.page_index {
let Ok(page_dict) = self.doc.get_dictionary(id) else { 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() {
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 name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into());
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
}
if subtype == "Link" {
if let Some(link) = self.link(page, d) {
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
info.has_javascript = true;
}
info.links.push(link);
}
continue;
}
if matches!(subtype.as_str(), "Widget" | "Popup") {
continue;
}
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
let 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,
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
info.annotations.push(Annotation {
page,
subtype,
author: self.text(d, b"T"),
contents: self.text(d, b"Contents"),
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
name: self.text(d, b"NM"),
in_reply_to,
rect,
color,
});
}
}
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, d: &Dictionary) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
LinkTarget::Page(self.dest_page(dest, 0)?)
} else {
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
match self.name(a, b"S").as_deref() {
Some("GoTo") => LinkTarget::Page(self.dest_page(a.get(b"D").ok()?, 0)?),
Some("URI") => {
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
}
Some(other) => LinkTarget::Other(other.to_string()),
None => return None,
}
};
Some(Link { page, rect, target })
}
// ── form fields ─────────────────────────────────────────────────────────────────────────
fn field(
&self,
o: &'a Object,
parent: Option<(&str, Option<String>, Option<i64>)>,
out: &mut Vec<Field>,
seen: &mut HashSet<ObjectId>,
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(v @ Object::String(..)) => lopdf::decode_text_string(v).ok(),
Ok(Object::Array(a)) => Some(a.iter().filter_map(|x| lopdf::decode_text_string(self.resolve(x)).ok()).collect::<Vec<_>>().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 });
}
fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option<usize> {
let targets: HashSet<ObjectId> =
std::iter::once(field).chain(kids.iter()).filter_map(|o| if let Object::Reference(id) = o { Some(*id) } else { None }).collect();
if targets.is_empty() {
return None;
}
self.page_index.iter().find_map(|(&pid, &idx)| {
let annots = self.doc.get_dictionary(pid).ok()?.get(b"Annots").ok()?;
let arr = self.resolve(annots).as_array().ok()?;
arr.iter().any(|a| matches!(a, Object::Reference(id) if targets.contains(id))).then_some(idx)
})
}
// ── optional content ────────────────────────────────────────────────────────────────────
fn layers(&self, catalog: &Dictionary, out: &mut Vec<Layer>) {
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<ObjectId> = 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<ObjectId> = 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 });
}
}
fn fonts(&self, out: &mut Vec<FontInfo>) {
let mut seen = HashSet::new();
let mut pages: Vec<_> = self.page_index.iter().collect();
pages.sort_by_key(|(_, i)| **i);
for (&pid, _) in pages {
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: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned();
let desc = descriptor_of(f).or_else(|| {
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))
.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());
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 info = FontInfo { name, kind, embedded, subset, encoding };
if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
out.push(info);
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
}
fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> 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.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source }
}
}
fn rect4(o: &Object) -> [f32; 4] {
match o {
Object::Array(a) if a.len() == 4 => {
let v: Vec<f32> = a.iter().map(|x| x.as_float().unwrap_or(0.0)).collect();
[v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]
}
_ => [0.0; 4],
}
}
fn roman(mut n: usize) -> String {
const T: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut s = String::new();
for (v, r) in T {
while n >= v {
s.push_str(r);
n -= v;
}
}
s
}
/// a..z, aa..zz, aaa.. (ISO 32000-2: "a to z for the first 26 pages, aa to zz for the next 26").
fn alpha(n: usize) -> String {
let n = n.max(1) - 1;
let letter = (b'a' + (n % 26) as u8) as char;
std::iter::repeat_n(letter, n / 26 + 1).collect()
}
/// `D:20260930104512-04'00'` → `2026-09-30 10:45`.
fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) {
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<Vec<u8>, String> {
let run = || -> Result<Vec<u8>, String> {
let options = LoadOptions { password: password.map(str::to_owned), ..LoadOptions::default() };
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")?;
let mut seen = HashSet::new();
insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).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))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roman_and_alpha_labels() {
assert_eq!(roman(4), "iv");
assert_eq!(roman(1994), "mcmxciv");
assert_eq!(alpha(1), "a");
assert_eq!(alpha(27), "aa");
assert_eq!(alpha(53), "aaa");
}
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);
}
assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
assert_eq!(pretty_date("yesterday"), "yesterday");
}
}