Files
pdfcraft/crates/a11y/src/headings.rs
T
aa6242d0c6 M4: bookmarks from tagged headings (organize.bookmarks-from-structure) (#754)
* Parity: merge the duplicate form.field-list evidence and notes

#649 and #650 each added `evidence` and `notes` to the same entry, so the
TOML has duplicate keys and `cargo xtask parity` fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M4: bookmarks from tagged headings (organize.bookmarks-from-structure)

Bookmark options > New bookmarks from structure (command
bookmark.from_structure, tool bookmark_from_structure) turns a tagged
document's headings into bookmarks, as Acrobat's New Bookmarks From
Structure does for headings: H1-H6 through the role map, a plain H by its
Sect/Part/Art nesting, nested by level under a new first "Untitled"
bookmark, each linked to its structure element (/SE, ISO 32000-2
§12.3.3). Titles come from /ActualText, the marked text drawn on the page
(decoded through the font's ToUnicode or encoding), /Alt or /T.

The structure walk is bounded (depth, visits, heading and title caps,
seen-set) and survives self-referencing elements; an untagged document
gets an actionable error; the edit is one undo step under the assemble
permission. Not yet: choosing other element types, and destinations at the
heading's position rather than its page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: size the shared raster allowance from thumbnail demand

The canvas test planted a 132×171 thumbnail whose tag did not match the
request finish_render_frame computes. On FreeBSD that real render finished
in the same frame, so the retained raster exceeded 132×171×4. Plant a
thumbnail that already satisfies demand and compare against that size.

* M4: a heading's text stops gathering once it is far past the title length

A heading whose marked-content list repeats one mark up to the visit cap could otherwise collect
about a gigabyte before the title is cut to 256 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:25:10 -04:00

235 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The document's headings (H, H1–H6 after the role map) with the text they mark: what New
//! Bookmarks from Structure turns into bookmarks.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_model::Page;
use crate::alt::{page_content, standard, tree_root};
const MAX_HEADINGS: usize = 10_000;
const MAX_VISITS: usize = 1_000_000;
const MAX_DEPTH: usize = 256;
/// Characters kept of a heading's title.
const MAX_TITLE: usize = 256;
/// A heading element.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
/// The structure element (`None` for a direct element, which can't be referenced).
pub obj: Option<ObjRef>,
/// 1 for H1 … 6 for H6; a plain H takes the number of sections (Sect, Part, Art) around it.
pub level: u8,
pub title: String,
/// 0-based page of its content.
pub page: usize,
}
struct Walker<'a> {
doc: &'a Document,
role_map: Dict,
pages: &'a [Page],
page_index: HashMap<ObjRef, usize>,
seen: HashSet<ObjRef>,
visits: usize,
/// Per page: the text each MCID shows (read once per page).
texts: HashMap<usize, HashMap<i64, String>>,
out: Vec<Heading>,
}
/// The headings, in document order. Headings without text or a page are left out.
pub fn headings(doc: &Document) -> Vec<Heading> {
let Some(root) = tree_root(doc) else { return Vec::new() };
let pages = pdfcraft_model::pages(doc);
let mut w = Walker {
doc,
role_map: root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(),
pages: &pages,
page_index: pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(),
seen: HashSet::new(),
visits: 0,
texts: HashMap::new(),
out: Vec::new(),
};
if let Some(k) = root.get(b"K") {
w.walk(k, None, 0, 0);
}
w.out
}
impl Walker<'_> {
/// Whether `k` may be visited (bounded depth and size, each object once).
fn enter(&mut self, k: &Object, depth: usize) -> bool {
self.visits += 1;
depth <= MAX_DEPTH && self.visits <= MAX_VISITS && self.out.len() < MAX_HEADINGS && !matches!(k, Object::Ref(r) if !self.seen.insert(*r))
}
fn element_of(&self, k: &Object) -> Option<(Option<ObjRef>, Dict)> {
let d = match k {
Object::Ref(r) => self.doc.get(*r).as_dict().cloned()?,
Object::Dict(d) => d.clone(),
_ => return None,
};
d.contains(b"S").then(|| (k.as_ref(), d))
}
fn walk(&mut self, k: &Object, pg: Option<ObjRef>, sections: u8, depth: usize) {
if !self.enter(k, depth) {
return;
}
if let Object::Array(a) = k {
a.iter().for_each(|x| self.walk(x, pg, sections, depth + 1));
return;
}
let Some((obj, d)) = self.element_of(k) else { return };
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
let ty = d.name(b"S").map(|s| standard(self.doc, &self.role_map, s)).unwrap_or_default();
let level = match ty.as_slice() {
[b'H', n @ b'1'..=b'6'] => Some(n - b'0'),
b"H" => Some(sections.max(1)),
_ => None,
};
if let Some(level) = level {
self.heading(obj, &d, pg, level, depth);
return;
}
let sections = if matches!(ty.as_slice(), b"Sect" | b"Part" | b"Art") { sections.saturating_add(1) } else { sections };
if let Some(kids) = d.get(b"K") {
self.walk(kids, pg, sections, depth + 1);
}
}
/// The marked content under `k`: (page, MCID) pairs in order.
fn marks(&mut self, k: &Object, pg: Option<ObjRef>, depth: usize, out: &mut Vec<(Option<ObjRef>, i64)>) {
if !self.enter(k, depth) {
return;
}
match k {
Object::Int(n) => out.push((pg, *n)),
Object::Array(a) => a.iter().for_each(|x| self.marks(x, pg, depth + 1, out)),
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
out.push((m.get(b"Pg").and_then(Object::as_ref).or(pg), n));
}
}
_ => {
if let Some((_, d)) = self.element_of(k)
&& let Some(kids) = d.get(b"K")
{
self.marks(kids, d.get(b"Pg").and_then(Object::as_ref).or(pg), depth + 1, out);
}
}
}
}
fn heading(&mut self, obj: Option<ObjRef>, d: &Dict, pg: Option<ObjRef>, level: u8, depth: usize) {
let mut marks = Vec::new();
if let Some(kids) = d.get(b"K") {
self.marks(kids, pg, depth + 1, &mut marks);
}
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
let doc = self.doc;
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
let title = match attr(b"ActualText") {
Some(t) => Some(t),
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
};
let Some(title) = title else { return };
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
self.out.push(Heading { obj, level, title, page });
}
/// The text the marked content shows (`page`: where marks without a page of their own are).
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
let mut parts = Vec::new();
// The title is cut to MAX_TITLE characters later: stop gathering well before a heading
// that repeats one mark many times can pile up gigabytes.
let mut gathered = 0usize;
for (p, id) in marks {
if gathered >= MAX_TITLE * 4 {
break;
}
let page = p.and_then(|p| self.page_index.get(&p).copied()).unwrap_or(page);
if let Some(text) = self.texts_of(page).get(id) {
gathered = gathered.saturating_add(text.len()).saturating_add(1);
parts.push(text.clone());
}
}
Some(parts.join(" ")).filter(|t| !t.trim().is_empty())
}
fn texts_of(&mut self, page: usize) -> &HashMap<i64, String> {
let (doc, pages) = (self.doc, self.pages);
self.texts.entry(page).or_insert_with(|| pages.get(page).map(|p| mcid_texts(doc, &p.dict)).unwrap_or_default())
}
}
fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
d.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
}
/// The text each marked-content id shows on a page (text drawn directly in the page content).
fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
let mut font: Option<Vec<u8>> = None;
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = HashMap::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
let id = stack.iter().rev().find_map(|m| *m);
match op.op.as_slice() {
b"BMC" => stack.push(None),
b"BDC" => stack.push(match op.operands.get(1) {
Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
Some(Object::Name(n)) => {
props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
}
_ => None,
}),
b"EMC" => {
stack.pop();
}
b"Tf" => {
font = op.name(0).map(<[u8]>::to_vec);
if let Some(name) = op.name(0)
&& !metrics.contains_key(name)
&& let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
{
metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f));
}
}
_ => {}
}
let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue };
let text = out.entry(id).or_default();
if text.len() >= MAX_TITLE * 4 {
continue;
}
match op.op.as_slice() {
b"Tj" | b"'" | b"\"" => {
if op.op != b"Tj" {
text.push(' ');
}
if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) {
text.push_str(&m.decode(&s.bytes));
}
}
b"TJ" => {
for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() {
match item {
Object::String(s) => text.push_str(&m.decode(&s.bytes)),
n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '),
_ => {}
}
}
}
b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '),
_ => {}
}
}
out
}