mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
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:
+20
-2413
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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(¬e) {
|
||||
notes.push(note);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if matches!(*style, Some(b"R" | b"A")) {
|
||||
number.make_ascii_uppercase();
|
||||
}
|
||||
let mut label = prefix.clone();
|
||||
label.push_str(&number);
|
||||
if label.is_empty() {
|
||||
continue; // Keep the existing physical number for an empty custom label.
|
||||
}
|
||||
total = total.checked_add(label.len()).ok_or("the total label size overflows")?;
|
||||
if total > MAX_LABEL_TOTAL_BYTES {
|
||||
return Err("custom labels exceed 4 MiB in total");
|
||||
}
|
||||
staged.push((i, label));
|
||||
}
|
||||
// A malformed later range must not leave a partially applied custom label sequence.
|
||||
for (i, label) in staged {
|
||||
if let Some(page) = pages.get_mut(i) {
|
||||
page.label = label;
|
||||
}
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
}
|
||||
|
||||
/// A text string's value, decoded by the same rules as the engine's object model
|
||||
/// ([`pdfcraft_cos::PdfString::to_text`]) so the panels and outline editing agree.
|
||||
pub(super) fn text_string(bytes: &[u8]) -> String {
|
||||
pdfcraft_cos::PdfString::literal(bytes).to_text()
|
||||
}
|
||||
|
||||
/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names`
|
||||
/// array objects it reached by reference.
|
||||
#[derive(Default)]
|
||||
pub(super) struct NameTreeSeen {
|
||||
nodes: HashSet<*const Dictionary>,
|
||||
objects: HashSet<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)));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user