refactor(render): split inspect.rs into inspect/

New modules: core, annotations, resources, navigation, model. The small test modules
(version_tests, open_action_javascript_tests, substitution_tests, open_dest_tests) moved with the code they test.
This commit is contained in:
aiongg
2026-10-26 22:18:50 +00:00
parent ef3fcbce02
commit faf57b4755
6 changed files with 2468 additions and 2413 deletions
File diff suppressed because it is too large Load Diff
+474
View File
@@ -0,0 +1,474 @@
use super::core::{limited_pdf_string, pretty_date, spend};
use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies};
use super::navigation::text_string;
use super::*;
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
/// File specifications retained from a remote-Go-To action are deliberately small.
pub(super) const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest
/// are not listed: real comments stay far below, many small ones sharing one long string do not.
pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20;
/// Web-address text the link listing, and name, description and embedded-file key text the
/// attachment listing, keep together. Many links or attachments can share one long string, which
/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its
/// address, an attachment under its generic name without a description, and an embedded file whose
/// key does not fit is not listed.
pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
/// The bytes a listed annotation keeps.
pub(super) fn retained(a: &Annotation) -> usize {
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
text.into_iter()
.map(|t| t.as_ref().map_or(0, String::len))
.chain([
a.subtype.len(),
std::mem::size_of_val(a.quads.as_slice()),
std::mem::size_of_val(a.points.as_slice()),
std::mem::size_of::<Annotation>(),
])
.fold(0, usize::saturating_add)
}
impl<'a> Inspector<'a> {
/// Whether an annotation is a Fill & Sign mark. Keep in step with
/// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses.
fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool {
let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
if yes(b"PCFillSign") {
return true;
}
match subtype {
"FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"),
"Stamp" => match self.name(d, b"Name").as_deref() {
Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true,
Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"),
_ => false,
},
"Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"),
_ => false,
}
}
// ── annotations ─────────────────────────────────────────────────────────────────────────
/// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`].
pub(super) fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) {
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
pages.sort_unstable_by_key(|&(_, page)| page);
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
// a reference resolves to, since an indirect object may itself hold a reference. Where each
// annotation is listed is collected before any is read, so a reply's `/IRT` finds its
// parent by reference even when the parent comes later.
let mut seen_arrays = HashSet::new();
let mut slots: HashMap<ObjectId, (usize, usize)> = HashMap::new();
let mut entries = Vec::new();
for (id, page) in pages {
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
if let Ok(annots) = page_dict.get(b"Annots")
&& let Some(r) = self.resolved_id(annots)
&& !seen_arrays.insert(r)
{
continue;
}
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for (index, a) in annots.iter().enumerate() {
if let Some(r) = self.resolved_id(a) {
let Entry::Vacant(slot) = slots.entry(r) else { continue };
slot.insert((page, index));
}
entries.push((page, index, a));
}
}
let mut kept = 0usize;
// Once the comment budget is spent, comments are no longer built, but links and attachments
// are still listed.
let mut comments_full = false;
for (page, index, a) in entries {
let Some(d) = self.dict(a) else { continue };
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::<f32>().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<f32> = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
v.as_chunks::<8>().0.to_vec()
}
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied());
let fill_sign = self.is_fill_sign(d, &subtype);
// Keep in step with `pdfcraft_annot::summaries`.
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
let points = match d.get(key).map(|o| self.resolve(o)) {
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
}
_ => Vec::new(),
};
let annotation = Annotation {
page,
subtype,
author: self.text(d, b"T"),
// Acrobat ends lines with CR, which the interface would draw as nothing (keep
// in step with `pdfcraft_annot::summaries`).
contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
modified: self.text(d, b"M").map(|m| pretty_date(&m)),
name: self.text(d, b"NM"),
in_reply_to,
parent,
grouped: self.name(d, b"RT").as_deref() == Some("Group"),
rect,
color,
index,
state: self.text(d, b"State"),
quads,
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
fill_sign,
no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0),
points,
};
kept = kept.saturating_add(retained(&annotation));
if kept > MAX_ANNOTATION_BYTES {
comments_full = true;
continue;
}
info.annotations.push(annotation);
}
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
link_replies(&mut info.annotations);
}
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
let (page, view) = self.dest(dest, 0)?;
LinkTarget::Page(page, view)
} else {
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<LinkTarget> {
let file = self.action_file(action)?;
let destination = match action.get(b"D") {
Err(_) => RemoteDestination::Default,
Ok(o) => match self.resolve(o) {
Object::Array(items) => {
let page = items.first().and_then(|o| self.resolve(o).as_i64().ok()).and_then(|n| usize::try_from(n).ok())?;
let kind = items.get(1).map(|o| self.resolve(o)).and_then(|o| o.as_name().ok());
let operands: [Option<f32>; 4] = std::array::from_fn(|i| items.get(i + 2).and_then(|o| self.resolve(o).as_float().ok()));
RemoteDestination::Page { page, view: DestView::from_operands(kind, &operands) }
}
Object::Name(name) if name.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
let name = String::from_utf8_lossy(name).into_owned();
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
return None;
}
RemoteDestination::Named(name)
}
Object::String(bytes, _) if bytes.len() <= MAX_REMOTE_DESTINATION_NAME_BYTES => {
let name = text_string(bytes);
if name.is_empty() || name.len() > MAX_REMOTE_DESTINATION_NAME_BYTES {
return None;
}
RemoteDestination::Named(name)
}
_ => return None,
},
};
Some(LinkTarget::RemoteGoTo { launch: false, file, destination, new_window: self.new_window(action) })
}
/// The non-empty local file an action's `/F` names (a string or a file specification), shared
/// by GoToR and Launch. A URL file system (`/FS /URL`) names no local file.
fn action_file(&self, action: &Dictionary) -> Option<String> {
if self.name(action, b"FS").as_deref() == Some("URL") {
return None;
}
let file = match self.resolve(action.get(b"F").ok()?) {
Object::String(bytes, _) => limited_pdf_string(bytes, MAX_REMOTE_FILE_SPEC_BYTES)?,
Object::Dictionary(spec) => {
if self.name(spec, b"FS").as_deref() == Some("URL") {
return None;
}
spec.get(b"UF")
.ok()
.and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES))
.or_else(|| spec.get(b"F").ok().and_then(|o| self.pdf_text(o, MAX_REMOTE_FILE_SPEC_BYTES)))?
}
_ => return None,
};
(!file.is_empty()).then_some(file)
}
fn new_window(&self, action: &Dictionary) -> Option<bool> {
match action.get(b"NewWindow").map(|o| self.resolve(o)) {
Ok(Object::Boolean(value)) => Some(*value),
_ => None,
}
}
/// A Launch action (ISO 32000-2 §12.6.4.6, Table 207) whose `/F` names a `.pdf` file, read as
/// the remote Go-To to that file's first page that it amounts to (#4168). Launching anything
/// else, or with the deprecated `/Win` launch parameters (an application, an operation, its
/// arguments), is not followed: a document must not start programs. `\` separators, which
/// Windows writers put in such paths, are read as `/` (§7.11.2.1 separates components with
/// `/`) when the path has no `/` of its own. Launch has no `/D`, so one present (written
/// for GoToR, or junk) is ignored rather than checked as a destination.
fn launch_pdf(&self, action: &Dictionary) -> Option<LinkTarget> {
if action.has(b"Win") {
return None;
}
let file = self.action_file(action)?;
let new_window = self.new_window(action);
let file = if file.contains('/') { file } else { file.replace('\\', "/") };
let pdf = file.rsplit('/').next().is_some_and(|name| name.len() > 4 && name.to_ascii_lowercase().ends_with(".pdf"));
pdf.then_some(LinkTarget::RemoteGoTo { launch: true, file, destination: RemoteDestination::Default, new_window })
}
fn pdf_text(&self, o: &Object, max_bytes: usize) -> Option<String> {
match self.resolve(o) {
Object::String(bytes, _) => limited_pdf_string(bytes, max_bytes),
_ => None,
}
}
/// A set-OCG-state action: `/State` is `ON`, `OFF` or `Toggle`, each followed by the groups
/// it applies to; `/PreserveRB` is true unless it is `false`.
fn layer_state(&self, a: &Dictionary) -> LinkTarget {
let mut changes = Vec::new();
if let Ok(Object::Array(items)) = a.get(b"State").map(|o| self.resolve(o)) {
let mut op = None;
for item in items.iter().take(MAX_LAYER_STATE) {
match item {
Object::Name(n) => {
op = match n.as_slice() {
b"ON" => Some(LayerOp::On),
b"OFF" => Some(LayerOp::Off),
b"Toggle" => Some(LayerOp::Toggle),
_ => None,
}
}
Object::Reference(id) => changes.extend(op.map(|op| (op, *id))),
_ => {}
}
}
}
let preserve_rb = !matches!(a.get(b"PreserveRB").map(|o| self.resolve(o)), Ok(Object::Boolean(false)));
LinkTarget::SetLayers { changes, preserve_rb }
}
// ── form fields ─────────────────────────────────────────────────────────────────────────
pub(super) fn field(
&self,
o: &'a Object,
parent: Option<(&str, Option<String>, Option<i64>)>,
out: &mut Vec<Field>,
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(Object::String(bytes, _)) => Some(text_string(bytes)),
Ok(Object::Array(a)) => Some(
a.iter()
.filter_map(|x| match self.resolve(x) {
Object::String(bytes, _) => Some(text_string(bytes)),
_ => None,
})
.collect::<Vec<_>>()
.join(", "),
),
Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()),
_ => None,
};
// The widget is 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<usize> {
let pages = self.annotation_pages.get_or_init(|| self.annotation_pages());
std::iter::once(field).chain(kids).filter_map(|o| if let Object::Reference(id) = o { pages.get(id).copied() } else { None }).min()
}
/// Each annotation listed in a page's `/Annots`, with the first page that lists it.
fn annotation_pages(&self) -> HashMap<ObjectId, usize> {
let mut pages = HashMap::new();
for (idx, &pid) in self.page_ids.iter().enumerate() {
let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue };
let Ok(annots) = self.resolve(annots).as_array() else { continue };
for a in annots {
if let Object::Reference(id) = a {
pages.entry(*id).or_insert(idx);
}
}
}
pages
}
/// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an
/// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top.
pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet<ObjectId>) -> Option<ObjectId> {
let mut top = id;
let mut visited = HashSet::from([id]);
loop {
let o = Object::Reference(top);
let d = self.dict(&o)?;
let parent = match d.get(b"Parent") {
Ok(Object::Reference(p)) => Some(*p),
_ => None,
};
let Some(p) = parent else {
return if seen.contains(&top) { None } else { Some(top) };
};
if !visited.insert(p) || visited.len() > 64 {
return None;
}
if seen.contains(&p) {
return None;
}
top = p;
}
}
}
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
/// `u32` counts as no flags.
fn draw_flags(o: &Object) -> u32 {
let f = match o {
Object::Integer(i) => *i,
Object::Real(r) => *r as i64,
_ => 0,
};
u32::try_from(f).unwrap_or(0)
}
fn rect4(o: &Object) -> [f32; 4] {
match o {
Object::Array(a) if a.len() == 4 => {
let v: Vec<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],
}
}
+417
View File
@@ -0,0 +1,417 @@
use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES;
use super::model::{AttachmentSource, PageInfo, Xfa};
use super::navigation::text_string;
use super::*;
/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is
/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and
/// xref streams are far below this.
const LOAD_STREAM_LIMIT: usize = 256 << 20;
pub(super) fn load_options(password: Option<&str>) -> LoadOptions {
LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() }
}
/// Check that `bytes` still open as the same document: the parser reads them and the page
/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files)
/// but must still catch an edit that produced something the viewer cannot display, so the
/// caller can roll it back. Never panics: parser crashes are caught and reported.
pub fn check_pages(bytes: Arc<Vec<u8>>, 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<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: version_label(&format!("{:?}", pdf.version())), ..Default::default() };
let pages = catch_unwind(AssertUnwindSafe(|| {
let mut out = Vec::new();
for (i, page) in pdf.pages().iter().enumerate() {
let (w, h) = page.render_dimensions();
let c = page.intersected_crop_box();
let rotation = match page.rotation() {
hayro::hayro_syntax::page::Rotation::None => 0,
hayro::hayro_syntax::page::Rotation::Horizontal => 90,
hayro::hayro_syntax::page::Rotation::Flipped => 180,
hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270,
};
let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32];
// Degenerate boxes still get a usable placeholder size so layout never divides by zero.
let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) };
out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation });
}
out
}));
match pages {
Ok(p) => info.pages = p,
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
let options = load_options(password);
// Geometry no longer needs the renderer parser. Do not overlap its allocations with
// structural inspection, including the compatibility fallback for damaged files.
drop(pdf);
inspect_structure(&mut info, |tmp| {
// The lazy reader first; lopdf's repair/tolerance path when it can't expose the same
// page tree or an object a panel needs. No partial result is published.
let lazy = catch_unwind(AssertUnwindSafe(|| {
let doc = LazyStructure::new(bytes.clone(), password).ok()?;
let mut t = DocInfo { pages: tmp.pages.clone(), ..Default::default() };
let inspector = Inspector::new(&doc);
if inspector.page_index.len() != t.pages.len() {
return None;
}
inspector.fill(&mut t);
(!doc.failed()).then_some(t)
}))
.ok()
.flatten();
if let Some(t) = lazy {
*tmp = t;
return Ok(());
}
let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?;
Inspector::new(&doc).fill(tmp);
Ok(())
});
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
pub(super) fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() };
fill(&mut tmp)?;
Ok::<_, String>(tmp)
}));
match structure {
Ok(Ok(mut filled)) => {
filled.file_size = info.file_size;
filled.pdf_version = std::mem::take(&mut info.pdf_version);
*info = filled;
}
Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")),
Err(p) => {
info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p)));
}
}
}
pub(super) struct Inspector<'a> {
pub(super) doc: &'a dyn Structure,
pub(super) page_ids: Vec<ObjectId>,
pub(super) page_index: HashMap<ObjectId, usize>,
/// Named destinations (`/Names /Dests` tree), keyed by raw string bytes, built once.
/// Looking each name up by walking the tree was O(links × names): minutes on manuals.
pub(super) named: HashMap<Vec<u8>, &'a Object>,
/// The page of each annotation (the first page that lists it), built when a field's widget
/// has no `/P`. Searching every page's `/Annots` for each such field was O(fields ×
/// annotations): a second on a form of 8,000 fields.
pub(super) annotation_pages: OnceCell<HashMap<ObjectId, usize>>,
}
impl<'a> Inspector<'a> {
pub(super) fn new(doc: &'a dyn Structure) -> Self {
let page_ids = doc.page_ids();
let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect();
let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() };
let mut named = HashMap::new();
if let Some(tree) =
doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o))
{
me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named);
}
me.named = named;
me
}
pub(super) fn fill(&self, info: &mut DocInfo) {
// lopdf drops /Encrypt from the trailer once it has decrypted the file.
info.encrypted = self.doc.encrypted();
if let Some(d) = self.doc.trailer().get(b"Info").ok().and_then(|o| self.dict(o)) {
info.title = self.text(d, b"Title");
info.author = self.text(d, b"Author");
info.subject = self.text(d, b"Subject");
info.keywords = self.text(d, b"Keywords");
info.creator = self.text(d, b"Creator");
info.producer = self.text(d, b"Producer");
const STANDARD: [&[u8]; 9] =
[b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"];
// The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`,
// trimmed and without empty values): text strings only, so a name value is not a
// custom property, and the cap counts listed entries.
info.custom = d
.iter()
.filter(|(k, _)| !STANDARD.contains(&k.as_slice()))
.filter_map(|(k, v)| match self.resolve(v) {
Object::String(bytes, _) => {
let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string();
(!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v))
}
_ => None,
})
.take(256)
.collect();
}
let Ok(catalog) = self.doc.catalog() else { return };
info.tagged = catalog
.get(b"MarkInfo")
.ok()
.and_then(|o| self.dict(o))
.and_then(|m| m.get(b"Marked").ok())
// Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the
// flag is resolved before it is read as a boolean (#3671).
.map(|o| self.resolve(o))
.and_then(|o| o.as_bool().ok())
.unwrap_or(false);
match self.page_labels(catalog, &mut info.pages) {
Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))),
Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")),
}
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
(info.outline, info.outline_more) = self.outline_window(first);
}
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
self.annotations(info, &mut text_budget);
self.transitions(info);
let mut field_seen = HashSet::new();
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
&& let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array())
{
for f in fields {
self.field(f, None, &mut info.fields, &mut field_seen, 0);
}
}
// Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them);
// each is adopted through its topmost unlisted `/Parent`, so a split field stays one.
for &pid in &self.page_ids {
let Ok(page_dict) = self.doc.get_dictionary(pid) else { continue };
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
for a in annots {
let Some(d) = self.dict(a) else { continue };
if self.name(d, b"Subtype").as_deref() != Some("Widget")
|| (d.get(b"FT").is_err() && d.get(b"T").is_err() && d.get(b"Parent").is_err())
{
continue;
}
if let Object::Reference(id) = a
&& let Some(root) = self.unlisted_field(*id, &field_seen)
{
self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0);
}
}
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false);
if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) {
// A form with no fields is taken to be laid out from its template, unless its
// configuration says it is a static form (#3180): Designer writes
// `<dynamicRender>forbidden</dynamicRender>` for one, whose pages are the form.
let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden");
info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static });
}
self.layers(catalog, &mut info.layers);
info.layer_groups = self.layer_groups(catalog);
self.fonts(&mut info.fonts);
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
let mut seen = HashSet::new();
for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) {
// The key is kept a second time when it is also the name.
let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone());
info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback));
}
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
}
if let Ok(action) = catalog.get(b"OpenAction")
&& let Some(dict) = self.dict(action)
&& dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..])
{
info.has_javascript = true;
}
info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o));
}
pub(super) fn resolve(&self, o: &'a Object) -> &'a Object {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
/// The object a reference ends at after following any chain of references; `None` for a
/// direct object or a broken reference.
pub(super) fn resolved_id(&self, o: &Object) -> Option<ObjectId> {
self.doc.dereference(o).ok().and_then(|(id, _)| id)
}
pub(super) fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> {
match self.resolve(o) {
Object::Dictionary(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
}
}
pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
let s = trimmed_text(self.resolve(d.get(key).ok()?))?;
(!s.is_empty()).then_some(s)
}
/// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims
/// short or to nothing still spends its raw length.
pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
let o = self.resolve(d.get(key).ok()?);
let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None };
let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?;
(!text.is_empty()).then_some(text)
}
pub(super) fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
}
/// A string or name value as text, without surrounding NULs and white space.
fn trimmed_text(o: &Object) -> Option<String> {
let s = match o {
Object::String(bytes, _) => text_string(bytes),
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
Some(s.trim_matches('\0').trim().to_string())
}
/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw
/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes
/// out too long spends the raw length, so a string many entries share is converted only until the
/// budget runs out.
pub(super) fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option<String> {
if bytes.len() > *budget {
return None;
}
let text = convert(bytes);
if text.len() > *budget {
*budget = budget.saturating_sub(bytes.len());
return None;
}
// The conversion's work follows the raw length, even when the text it keeps is shorter.
*budget = budget.saturating_sub(text.len().max(bytes.len()));
Some(text)
}
pub(super) fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option<String> {
if bytes.len() > max_bytes {
return None;
}
let text = text_string(bytes);
(text.len() <= max_bytes).then_some(text)
}
/// `D:20260930104512-04'00'` → `2026-09-30 10:45`.
/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged.
pub fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) {
// Twelve ASCII digits make every slice below a UTF-8 character boundary.
format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12])
} else {
s.to_string()
}
}
/// "1.7" from the parser's version name ("Pdf17", "V1_7", …).
fn version_label(name: &str) -> String {
let digits: Vec<char> = name.chars().filter(char::is_ascii_digit).collect();
match digits[..] {
[a, b] => format!("{a}.{b}"),
_ => name.to_owned(),
}
}
#[cfg(test)]
mod version_tests {
#[test]
fn version_labels_read_as_numbers() {
assert_eq!(super::version_label("Pdf17"), "1.7");
assert_eq!(super::version_label("V2_0"), "2.0");
assert_eq!(super::version_label("Unknown"), "Unknown");
}
}
#[cfg(test)]
mod open_action_javascript_tests {
use std::sync::Arc;
use super::inspect;
/// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller).
fn pdf_with_open_action(action: &[u8]) -> Vec<u8> {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>",
action,
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn open_action_javascript_sets_has_javascript() {
let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>");
let info = inspect(Arc::new(bytes), None).expect("inspects");
assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript");
}
#[test]
fn open_action_goto_does_not_set_has_javascript() {
let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>");
let info = inspect(Arc::new(bytes), None).expect("inspects");
assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
}
}
+539
View File
@@ -0,0 +1,539 @@
use super::*;
/// What kind of XFA form a document has (`/AcroForm /XFA`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Xfa {
/// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold
/// its data, which Acrobat shows in preference to the fields).
Static,
/// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a
/// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder.
Dynamic,
}
/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes
/// into view in a full-screen presentation, and how long it stays before advancing. Only pages
/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the
/// defaults rather than being refused.
#[derive(Clone, Debug, PartialEq)]
pub struct PageTransition {
pub page: usize,
/// `/Trans /S`; `None` when the page has only `/Dur`.
pub style: Option<TransitionStyle>,
/// `/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<u16>,
/// `/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<f32>,
}
/// 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<f32>, y: Option<f32>) -> [Option<f32>; 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<f32>, y: Option<f32>, view_rotation: u16) -> [Option<f32>; 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<f32>| 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<bool>,
},
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<f32>, top: Option<f32>, zoom: Option<f32> },
/// `/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<f32> },
/// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge.
FitV { left: Option<f32> },
/// `/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<f32>]) -> 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<usize>,
/// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page).
pub view: DestView,
pub children: Vec<OutlineItem>,
/// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3,
/// Table 151). A negative one is closed, and so is a missing one, which a bookmark with
/// children should not have.
pub open: bool,
/// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the
/// default black or a value that isn't three numbers.
pub color: Option<[f32; 3]>,
/// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold.
pub italic: bool,
pub bold: bool,
}
#[derive(Clone, Debug)]
pub struct Annotation {
pub page: usize,
pub subtype: String,
pub author: Option<String>,
pub contents: Option<String>,
pub modified: Option<String>,
/// `/NM` of this annotation, and of the one it replies to (`/IRT`).
pub name: Option<String>,
pub in_reply_to: Option<String>,
/// The page and `/Annots` position of the comment `/IRT` points at, found by object
/// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF
/// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed
/// comment ([`link_replies`]).
pub parent: Option<(usize, usize)>,
/// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its
/// strikeout) rather than a reply to it.
pub grouped: bool,
/// Rect in PDF user space: [x0, y0, x1, y1].
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
/// Position in the page's `/Annots` (how edits address the comment).
pub index: usize,
/// For a status reply (`/State`, Acrobat's "Set status"): the state it sets.
pub state: Option<String>,
/// 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<String>,
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
pub fill_sign: bool,
/// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the
/// appearance stays upright, turned about the upper-left corner of `rect`
/// ([`Self::shown_rect`]).
pub no_rotate: bool,
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
}
impl Annotation {
/// A checkmark reply (`/StateModel /Marked`): "Marked" or "Unmarked" rather than a review status.
pub fn is_mark(&self) -> bool {
matches!(self.state.as_deref(), Some("Marked" | "Unmarked"))
}
/// A reply (or status reply) to another comment rather than a comment of its own.
pub fn is_reply(&self) -> bool {
self.parent.is_some()
}
/// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't
/// be found (deleted from another page, or never listed) has nothing to show, so it is listed
/// nowhere: not as a card, on the page or in a count.
pub fn is_comment(&self) -> bool {
!self.is_reply() && self.state.is_none()
}
/// Whether this annotation's `/IRT` points at `other`.
pub fn replies_to(&self, other: &Annotation) -> bool {
self.parent == Some((other.page, other.index))
}
/// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user
/// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner
/// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment
/// use this, so they match the drawing.
pub fn shown_rect(&self, rotation: u16) -> [f32; 4] {
self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32)
}
/// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or
/// resized).
pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] {
if self.no_rotate { upright_rect(rect, rotation) } else { rect }
}
/// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of
/// [`Self::shown`], for a box resized as drawn.
pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] {
if self.no_rotate { rect_from_upright(shown, rotation) } else { shown }
}
}
/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned
/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its
/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's
/// top-left is that corner, the rectangle's width runs across and its height down, as on an
/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored
/// renderer draws the appearance there (hayro-interpret patch 37).
fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] {
let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
let (w, h) = (x1 - x0, y1 - y0);
match rotation {
90 => [x0, y1, x0 + h, y1 + w],
180 => [x0 - w, y1, x0, y1 + h],
270 => [x0 - h, y1 - w, x0, y1],
_ => [x0, y0, x1, y1],
}
}
/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that
/// stays put.
fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] {
let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])];
let (sw, sh) = (s2 - s0, s3 - s1);
// The upper-left corner of `/Rect` and the rectangle's width and height.
let ((x0, y1), w, h) = match rotation {
90 => ((s0, s1), sh, sw),
180 => ((s2, s1), sw, sh),
270 => ((s2, s3), sh, sw),
_ => return [s0, s1, s2, s3],
};
[x0, y1 - h, x0 + w, y1]
}
/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when
/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted
/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays
/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list
/// after an edit) run this.
///
/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other
/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an
/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list
/// has them.
pub fn link_replies(annotations: &mut [Annotation]) {
let at: HashMap<(usize, usize), usize> =
annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect();
let links: Vec<Option<usize>> =
annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect();
for (i, a) in annotations.iter_mut().enumerate() {
let mut up = links.get(i).copied().flatten();
let mut settled = false;
for _ in 0..64 {
let Some(j) = up else { break };
up = links.get(j).copied().flatten();
if up.is_none() {
settled = true;
break;
}
}
if !settled {
a.parent = None;
}
}
}
/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a
/// reply to a reply files under that comment too. Expects links [`link_replies`] settled.
pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> {
let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect();
parents
.iter()
.map(|(&reply, &first)| {
let mut top = first;
for _ in 0..64 {
let Some(&up) = parents.get(&top) else { break };
top = up;
}
(reply, top)
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
Text,
CheckBox,
Radio,
PushButton,
Combo,
List,
Signature,
Unknown,
}
#[derive(Clone, Debug)]
pub struct Field {
pub name: String,
pub kind: FieldKind,
pub value: Option<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,
/// 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<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>,
/// 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<String>,
/// 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<String>,
}
+550
View File
@@ -0,0 +1,550 @@
#[cfg(test)]
use super::core::inspect;
use super::core::spend;
use super::model::{DestView, OutlineItem, PageInfo};
use super::*;
/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document
/// reads only this window; the rest are listed by page through `pdfcraft_organize`.
pub(super) const OUTLINE_WINDOW: usize = 10_000;
/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out.
pub(super) const OUTLINE_DEPTH: usize = 32;
impl<'a> Inspector<'a> {
/// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit
/// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array
/// or an action) a destination name or string; a name or string is looked up as a named
/// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and
/// neither has a page number past the end of the document.
pub(super) fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> {
let dest = match self.resolve(action) {
Object::Dictionary(d) => {
if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) {
return None;
}
d.get(b"D").ok()?
}
other => other,
};
self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len())
}
pub(super) fn page_of(&self, o: &Object) -> Option<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 ────────────────────────────────────────────────────────────────────────
/// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …),
/// a dictionary with `/D`, or a named destination that resolves to one.
pub(super) fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> {
if depth > 8 {
return None;
}
match self.resolve(dest) {
Object::Array(a) => {
let page = a.first().and_then(|p| self.page_of(p))?;
let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok());
let operands: Vec<Option<f32>> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect();
Some((page, DestView::from_operands(kind, &operands)))
}
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)),
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)),
_ => None,
}
}
fn named_dest(&self, key: &[u8]) -> Option<&'a Object> {
let catalog = self.doc.catalog().ok()?;
if let Some(v) = self.named.get(key) {
return Some(v);
}
// PDF 1.1 style /Dests dictionary.
catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok())
}
/// A name tree's leaves keyed by their raw key bytes (first occurrence wins).
pub(super) fn name_tree_raw(
&self,
node: &'a Dictionary,
seen: &mut HashSet<*const Dictionary>,
depth: u32,
out: &mut HashMap<Vec<u8>, &'a Object>,
) {
if depth > 32 || !seen.insert(node as *const _) {
return;
}
if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) {
for pair in pairs.chunks(2) {
if let [k, v] = pair {
let key = match self.resolve(k) {
Object::String(s, _) => s.clone(),
Object::Name(n) => n.clone(),
_ => continue,
};
out.entry(key).or_insert(v);
}
}
}
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k) {
self.name_tree_raw(d, seen, depth + 1, out);
}
}
}
}
/// The entries of an embedded-files name tree. Each string key is spent from `text_budget`
/// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out.
pub(super) fn name_tree(
&self,
node: &'a Dictionary,
seen: &mut HashSet<*const Dictionary>,
depth: u32,
text_budget: &mut usize,
) -> Vec<(String, &'a Object)> {
let mut out = Vec::new();
if depth > 32 || !seen.insert(node as *const _) {
return out;
}
if let Ok(Object::Array(pairs)) = node.get(b"Names").map(|o| self.resolve(o)) {
for pair in pairs.chunks(2) {
if let [k, v] = pair {
let key = match self.resolve(k) {
Object::String(bytes, _) => match spend(bytes, text_budget, text_string) {
Some(key) => key,
None => continue,
},
_ => String::new(),
};
out.push((key, v));
}
}
}
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k) {
out.extend(self.name_tree(d, seen, depth + 1, text_budget));
}
}
}
out
}
/// The first entry of an embedded-files name tree whose key is `key`, in the order
/// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key
/// object or `/Names` array that several places reference only once (a second visit cannot
/// match, since the first would have returned).
pub(super) fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> {
if depth > 32 || !seen.nodes.insert(node as *const _) {
return None;
}
let names = node.get(b"Names").ok();
if let Some(names) = names
&& self.resolved_id(names).is_none_or(|id| seen.objects.insert(id))
&& let Object::Array(pairs) = self.resolve(names)
{
for pair in pairs.chunks(2) {
if let [k, v] = pair {
if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) {
continue;
}
let found = match self.resolve(k) {
Object::String(bytes, _) => text_string(bytes) == key,
_ => key.is_empty(),
};
if found {
return Some(v);
}
}
}
}
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
for k in kids {
if let Some(d) = self.dict(k)
&& let Some(v) = self.name_tree_find(d, key, seen, depth + 1)
{
return Some(v);
}
}
}
None
}
// ── outline ─────────────────────────────────────────────────────────────────────────────
/// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order,
/// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and
/// each bookmark is read once, so the work and memory stay within the window.
pub(super) fn outline_window(&self, first: &'a Object) -> (Vec<OutlineItem>, 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<Option<&'a Object>> = 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<Option<&'a Dictionary>, &'static str> {
Ok(match self.label_object(object)? {
Object::Dictionary(d) => Some(d),
Object::Stream(s) => Some(&s.dict),
_ => None,
})
}
/// Apply `/PageLabels` to `pages`. `Err`: the tree is unusable and no label is applied.
/// `Ok(notes)`: labels applied, with what was repaired on the way (a duplicated start page,
/// a range whose labels are too long to show).
pub(super) fn page_labels(&self, catalog: &Dictionary, pages: &mut [PageInfo]) -> Result<Vec<String>, &'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<usize, &Dictionary> = 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(&note) {
notes.push(note);
}
continue;
};
if matches!(*style, Some(b"R" | b"A")) {
number.make_ascii_uppercase();
}
let mut label = prefix.clone();
label.push_str(&number);
if label.is_empty() {
continue; // Keep the existing physical number for an empty custom label.
}
total = total.checked_add(label.len()).ok_or("the total label size overflows")?;
if total > MAX_LABEL_TOTAL_BYTES {
return Err("custom labels exceed 4 MiB in total");
}
staged.push((i, label));
}
// A malformed later range must not leave a partially applied custom label sequence.
for (i, label) in staged {
if let Some(page) = pages.get_mut(i) {
page.label = label;
}
}
Ok(notes)
}
}
/// A text string's value, decoded by the same rules as the engine's object model
/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree.
pub(super) fn text_string(bytes: &[u8]) -> String {
pdfcraft_cos::PdfString::literal(bytes).to_text()
}
/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names`
/// array objects it reached by reference.
#[derive(Default)]
pub(super) struct NameTreeSeen {
nodes: HashSet<*const Dictionary>,
objects: HashSet<ObjectId>,
}
/// 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<Item = Option<f32>>) -> Option<[f32; 3]> {
let v: Vec<Option<f32>> = 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<OutlineItem> {
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<OutlineItem>) {
if let Some((_, item)) = open.pop() {
match open.last_mut() {
Some((_, parent)) => parent.children.push(item),
None => roots.push(item),
}
}
}
fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> {
*left = left.checked_sub(count).ok_or("the number tree has too many entries")?;
Ok(())
}
#[cfg(test)]
mod open_dest_tests {
use std::sync::Arc;
use super::{DestView, inspect};
/// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and
/// `extra` become objects 6, 7, ….
fn pdf(catalog: &str, extra: &[String]) -> Vec<u8> {
let mut objs =
vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()];
for _ in 0..3 {
objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string());
}
objs.extend(extra.iter().cloned());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> {
inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest
}
const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) };
#[test]
fn an_explicit_destination_opens_at_its_page_and_position() {
assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ)));
assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) })));
// Indirect, and a zoom of 0 keeps the zoom (Table 149).
assert_eq!(
open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]),
Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None }))
);
}
#[test]
fn a_go_to_action_opens_at_its_destination_explicit_or_named() {
assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ)));
// A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4).
assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ)));
// A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D.
assert_eq!(
open_dest(
"/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>",
&["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()]
),
Some((2, XYZ))
);
}
#[test]
fn an_open_action_given_as_a_name_or_string_is_looked_up() {
assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit)));
assert_eq!(
open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]),
Some((2, DestView::FitV { left: Some(30.0) }))
);
}
#[test]
fn an_open_action_that_goes_nowhere_here_has_no_destination() {
for catalog in [
"",
"/OpenAction << /S /JavaScript /JS (app.alert(1)) >>",
// A remote go-to's /D is a page of another file.
"/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>",
"/OpenAction << /D [4 0 R /Fit] >>",
// An unknown name, and no name dictionaries at all.
"/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>",
"/OpenAction /missing",
// A page number past the end, and a page reference that is no page.
"/OpenAction [7 /Fit]",
"/OpenAction [2 0 R /Fit]",
"/OpenAction 42",
] {
assert_eq!(open_dest(catalog, &[]), None, "{catalog}");
}
}
#[test]
fn cyclic_and_deep_named_destinations_end() {
// Names that refer to each other.
assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None);
assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None);
// A name tree whose node is its own kid.
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None);
// A name tree deeper than the depth bound: the leaf past it isn't read.
let depth = 40;
let mut nodes: Vec<String> = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect();
nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into());
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None);
// A shallow one is read.
let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()];
assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit)));
}
}
+468
View File
@@ -0,0 +1,468 @@
#[cfg(test)]
use super::core::inspect;
use super::core::{Inspector, load_options};
use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle};
use super::navigation::NameTreeSeen;
use super::*;
/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to
/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template.
const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
/// At most this many layers are read from all of `/RBGroups` together.
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
impl<'a> Inspector<'a> {
// ── object helpers ──────────────────────────────────────────────────────────────────────
/// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value
/// outside the spec's choices takes that entry's default, and a page with neither entry is
/// not listed. Bounded by the page count.
pub(super) fn transitions(&self, info: &mut DocInfo) {
for (page, &pid) in self.page_ids.iter().enumerate() {
let Ok(d) = self.doc.get_dictionary(pid) else { continue };
let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0);
let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o));
if trans.is_none() && display.is_none() {
continue;
}
let mut t = PageTransition {
page,
style: None,
duration: 1.0,
horizontal: true,
inward: true,
direction: Some(0),
scale: 1.0,
opaque: false,
display,
};
if let Some(tr) = trans {
t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n)));
let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite());
t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0);
t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V");
t.inward = self.name(tr, b"M").as_deref() != Some("O");
t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) {
Ok(Object::Name(n)) if n.as_slice() == b"None" => None,
Ok(o) => match o.as_i64().ok() {
Some(0) => Some(0),
Some(90) => Some(90),
Some(180) => Some(180),
Some(270) => Some(270),
Some(315) => Some(315),
_ => Some(0),
},
Err(_) => Some(0),
};
t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0);
t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
}
info.transitions.push(t);
}
}
/// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under
/// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is
/// either one stream holding the whole XDP or an array of packet names and streams (ISO
/// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over
/// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element.
pub(super) fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option<String> {
let stream = match self.resolve(xfa) {
Object::Array(items) => {
let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?;
self.resolve(items.get(at.checked_add(1)?)?)
}
whole => whole,
};
let stream = stream.as_stream().ok()?;
let bytes = if stream.dict.get(b"Filter").is_ok() {
stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()?
} else {
stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec()
};
xfa_config_dynamic_render(&xfa_packet_text(&bytes))
}
// ── optional content ────────────────────────────────────────────────────────────────────
pub(super) fn layers(&self, catalog: &Dictionary, out: &mut Vec<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, initial: visible });
}
}
/// The default configuration's radio-button groups (`/RBGroups`) of two or more layers.
pub(super) fn layer_groups(&self, catalog: &Dictionary) -> Vec<Vec<ObjectId>> {
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<ObjectId> = members.iter().filter_map(|x| x.as_reference().ok()).take(left).collect();
left = left.saturating_sub(group.len());
if group.len() > 1 {
out.push(group);
}
if left == 0 {
break;
}
}
out
}
pub(super) fn fonts(&self, out: &mut Vec<FontInfo>) {
let mut seen = HashSet::new();
for &pid in &self.page_ids {
let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue };
for (_, f) in fonts {
let base = f
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).into_owned())
.unwrap_or_else(|| "(unnamed)".into());
let kind = self.name(f, b"Subtype").unwrap_or_default();
let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) {
Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()),
Ok(Object::Dictionary(_)) => Some("Custom".into()),
Ok(Object::Stream(_)) => Some("Embedded CMap".into()),
_ => None,
};
// Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font).
let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o));
let descendant =
f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o));
let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of));
let embedded =
kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok());
// What the substitution chain needs beyond the name: the descriptor flags, and for
// a composite font the script its descendant declares (ISO 32000-2 9.7.3).
let flags = desc
.and_then(|d| d.get(b"Flags").ok())
.and_then(|o| self.resolve(o).as_i64().ok())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
let ordering =
descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering"));
let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let name = if subset { base[7..].to_string() } else { base };
let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request {
base_font: &name,
embedded,
flags,
ordering: ordering.as_deref(),
});
let info = FontInfo {
name,
kind,
embedded,
subset,
encoding,
substitute: chosen.face_name(),
substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()),
};
if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
out.push(info);
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
}
/// The attachment's name is the file specification's, else `fallback`'s, else a generic one.
pub(super) fn attachment(
&self,
spec: &Object,
source: AttachmentSource,
text_budget: &mut usize,
fallback: impl FnOnce(&mut usize) -> Option<String>,
) -> Attachment {
let d = self.dict(spec);
let size =
d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
|f| match self.resolve(f) {
Object::Stream(s) => s
.dict
.get(b"Params")
.ok()
.and_then(|p| self.dict(p))
.and_then(|p| p.get(b"Size").ok())
.and_then(|s| s.as_i64().ok())
.map(|s| s as usize)
.or(Some(s.content.len())),
_ => None,
},
);
let display = d
.and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget)))
.or_else(|| fallback(text_budget))
.unwrap_or_else(|| "attachment".into());
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source }
}
}
/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs.
pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result<Vec<u8>, String> {
let run = || -> Result<Vec<u8>, String> {
let options = load_options(password);
let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?;
let insp = Inspector::new(&doc);
let spec: &Object = match &att.source {
AttachmentSource::Document { key } => {
let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?;
let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?;
insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")?
}
AttachmentSource::Annotation { page, index } => {
let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?;
let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?;
let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?;
insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")?
}
};
let fs = insp.dict(spec).ok_or("bad file specification")?;
let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?;
let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?;
let stream = stream.as_stream().map_err(|e| e.to_string())?;
if stream.dict.get(b"Filter").is_ok() {
stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string())
} else {
Ok(stream.content.clone())
}
};
catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p))))
}
/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by
/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one
/// element is looked for.
pub(super) fn xfa_packet_text(bytes: &[u8]) -> String {
let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| {
let units = b.as_chunks::<2>().0.iter().map(|c| from(*c));
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::<String>()
};
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 (`</x>`) or closes itself (`<x/>`), 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<Tag<'_>> {
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("<!--") {
from = skip_to("-->")?;
continue;
}
if rest.starts_with("<![CDATA[") {
from = skip_to("]]>")?;
continue;
}
if rest.starts_with("<?") || rest.starts_with("<!") {
from = skip_to(">")?;
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 `<name>…</name>` 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<String> {
let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?;
let text = body.trim();
(!text.is_empty() && !text.contains('<')).then(|| text.to_owned())
}
/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child
/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside
/// the form data or the template are not the configuration.
pub(super) fn xfa_config_dynamic_render(packet: &str) -> Option<String> {
const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"];
xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG))
}
#[cfg(test)]
mod substitution_tests {
use std::sync::Arc;
use super::{FontInfo, inspect};
/// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a
/// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased
/// standard-14 name.
const NOT_EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj
5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj
6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj
7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj
8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj
9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj
10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj
11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj
12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj
trailer << /Size 13 /Root 1 0 R >>
%%EOF";
fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo {
fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::<Vec<_>>()))
}
#[test]
fn doc_info_says_which_face_stands_in_and_why() {
let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens");
// The one that matters most to a user: a Chinese PDF that draws nothing. Before this,
// doc_info said only `embedded: false` and left them to guess.
let cjk = font(&info.fonts, "STSong-Light");
assert!(!cjk.embedded);
assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in");
let why = cjk.substitution.as_deref().unwrap_or_default();
assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}");
// An aliased standard-14 name resolves to the real face, whose metrics are exact.
let arial = font(&info.fonts, "ArialMT");
assert_eq!(arial.substitute.as_deref(), Some("Helvetica"));
assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14"));
// A house font whose name merely contains "Serif" gets Times -- reported as the guess it
// is, not as a standard-14 name, which would be untrue of a font nobody has heard of.
let serif = font(&info.fonts, "SomeHouseSerif");
assert_eq!(serif.substitute.as_deref(), Some("Times-Roman"));
assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}");
// No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic.
let grotesk = font(&info.fonts, "HouseGrotesk");
assert_eq!(grotesk.substitute.as_deref(), Some("Courier"));
assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}");
// Japanese depends on the optional craft-fonts build input; either way it is reported,
// and never as a Latin face.
let jpan = font(&info.fonts, "KozMinPro-Regular");
assert!(jpan.substitution.is_some(), "a substitution is always explained");
match &jpan.substitute {
Some(face) => {
assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}")
}
None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")),
}
// Nothing claims to be embedded, so every font here carries a reason.
assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts);
}
/// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain.
const EMBEDDED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj
4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj
5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj
6 0 obj << /Length 4 >> stream
test
endstream endobj
trailer << /Size 7 /Root 1 0 R >>
%%EOF";
#[test]
fn an_embedded_font_reports_no_substitution_at_all() {
let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens");
let f = font(&info.fonts, "SomeHouseSans");
assert!(f.embedded && f.subset, "{f:?}");
assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown");
}
}