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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 08:25:10 -04:00
1 parent 0bc619cd02
commit aa6242d0c6
38 files changed
+588 -39

No files matched your search

+15 -15
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b4f5905c404dbc8841391aa9a35dbdf8ad78606df208f64ede3c336654025397"
sha256 = "c405d31d9b5a372f60f12cfb1192006b91d3056609216c1b93c0da25577be253"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d989733686d249c6cb8cddc0a4cafbe2896d0801bf59ad76a684d3b30306154b"
sha256 = "a0994ddd964023f1bdf813a5ee613d2964e5b055eaaa23950ed5f85d6258fad1"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d59a3fe00045668545ec5643ab3516f38fa004eacdedfe15cdca16a8af40722d"
sha256 = "36f02d47c9c669efaf164514ccd68a6821247487d7febc27abfae33312ce88ce"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f4f6d80aa0d213446f686bc6f930ba4a9e988441a659e0e0c623a81b1823ec19"
sha256 = "1406c6cc959145e2e374b8ed4cd27b996729b0074da7ed850da8d5b0e0b0069b"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "640881c5e7a15603027b26b7f8a23a735d0e9b6cb3697ed5b7d7f2626d3939bd"
sha256 = "52eb0c536f99645cc06768177b4a9c3a949ceb92c30b2e2a5ac65a119b855ac2"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "29357d34f7646a81c78c4c3d199757e306592caf352ea57581b36de0bdf0288f"
sha256 = "381d04db4a7e4acf52cf90245e7293711e7de0be397098eb713ffa8fb8367044"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7c06be24f43ba554a5f57d9702602869c0c65b6f630fd66b1d0c5b9994530baf"
sha256 = "c2700d35ac8b339875c15d381fc4c7415d58edad3bf27fd076ff489dccafdf07"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "83588462b7dfd3740e764bc734ddf0d09fd1004c1dca1fd3ca9d191bb3611876"
sha256 = "0bb7f55dcb9c5e196ea40cb7fa7ac25654f324148215e26ec6121d4f22b73fed"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e558d1b1374c5725b68792446b08ae31c35c7b6fd68c4a54d257b6ca439f0250"
sha256 = "00b40b6f2e746961d9ebe3ff19b05e280f123dac9999a98ba596cf4c0bae19b4"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f3ee03336b29ec87742aca37930f0ff7182e2e1f7a3672da588eea3334fa4dd7"
sha256 = "1dd0d60e7a4dc3261f5f50f34d60fcc290d8084ec9db5a1f88ed33213841a6f5"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4254b9bb4d050910f26df82d66740b46e3d7a8e33205edad053a4bdf5316f6f6"
sha256 = "832e71b4c59c324c60be5bf133a09c9072db69e0398dc6226963b3f5addbac0d"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0fa3e37945099a50d0bf5ad4c3191ab2eb1bc0d5ebe253cc459917395c0a83fa"
sha256 = "8b476bc0d6d03ae02e544c540b9af61f0e903f1f0f6400987a6d42399ab2a8a9"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a3b2a9f3068ab60dab4f1e18fe3d2be36275508772f39122cd26a03bd67527d9"
sha256 = "6033c48ff56b21ffe53400740f1e7989b93c8b6d40d4bdf0ea7b1ccd1b134c3e"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "51c933a8018726368dfaa55a82e8bc99a5684f80825bfdb6b581f03c5015908f"
sha256 = "d5f8d6f6b0c4cd0447464f9a83509e6d854f67968be910af3a14994cb532851c"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bb0808c269078ca6b63d7b2f2b8d1e058e12c89dde3ad6f28c848f6467e89671"
sha256 = "cef4cbb555852b5cb06de74d8e5a57995adfd304677dd7478758d89ccdb37e9b"
[[asset]]
path = "assets/icons/accessibility.svg"
Generated
+1
View File
@@ -3565,6 +3565,7 @@ version = "0.5.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
]
+3 -2
View File
@@ -98,7 +98,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
@@ -131,13 +131,14 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-10 (M4, 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: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
- **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14):** Arabic interface language: a 2,136-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), selected by `ar` and every `ar-*` locale. egui joins Arabic letters but places words left to right, so the catalog is put into display order when it loads (`bidi::display_rtl`: placeholders, keyboard shortcuts, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself.
- **2026-10-10 (M14):** Arabic interface language: a 2,137-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), selected by `ar` and every `ar-*` locale. egui joins Arabic letters but places words left to right, so the catalog is put into display order when it loads (`bidi::display_rtl`: placeholders, keyboard shortcuts, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself.
- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
+1
View File
@@ -14,4 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
+3 -3
View File
@@ -27,13 +27,13 @@ pub enum AltError {
Cos(#[from] pdfcraft_cos::CosError),
}
fn tree_root(doc: &Document) -> Option<Dict> {
pub(crate) fn tree_root(doc: &Document) -> Option<Dict> {
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?;
cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned())
}
/// The standard type of a structure type through the role map.
fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
pub(crate) fn standard(doc: &Document, role_map: &Dict, s: &[u8]) -> Vec<u8> {
let mut t = s.to_vec();
for _ in 0..12 {
if crate::structure::is_standard(&t) {
@@ -105,7 +105,7 @@ fn mcids(d: &Dict) -> Vec<i64> {
out
}
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
match &*doc.resolve(c) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
+234
View File
@@ -0,0 +1,234 @@
//! 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
}
+2
View File
@@ -14,10 +14,12 @@ use pdfcraft_cos::Document;
mod alt;
mod content;
mod headings;
mod report;
mod structure;
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
pub use headings::{Heading, headings};
pub use report::report_html;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+56
View File
@@ -279,3 +279,59 @@ fn setting_alt_text_and_marking_decorative() {
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
/// Two tagged pages: role-mapped and nested headings, actual text, a ToUnicode font, a heading
/// without text and a heading that lists itself among its kids.
fn headings_doc() -> Vec<String> {
vec![
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 8 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R /F2 9 0 R >> >> >>".into(),
stream(
"/H1 << /MCID 0 >> BDC BT /F1 12 Tf 20 180 Td (Chapter) Tj ( One) Tj ET EMC \
/P << /MCID 1 >> BDC BT (Body) Tj ET EMC \
/H << /MCID 2 >> BDC BT /F1 10 Tf [(Sec) -500 (A)] TJ ET EMC",
),
stream("/H2 << /MCID 0 >> BDC BT /F1 12 Tf (Ignored) Tj ET EMC /H3 << /MCID 1 >> BDC BT /F2 12 Tf <0102> Tj ET EMC"),
FONT.into(),
"<< /Type /StructTreeRoot /K 10 0 R /RoleMap << /Chapter /H1 >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Custom /ToUnicode 11 0 R >>".into(),
"<< /S /Document /K [12 0 R << /S /P /Pg 3 0 R /K 1 >> << /S /Sect /K << /S /Sect /K 13 0 R >> >> 14 0 R 15 0 R 16 0 R] >>".into(),
stream(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap 1 begincodespacerange <00> <FF> endcodespacerange \
2 beginbfchar <01> <0041> <02> <0042> endbfchar endcmap end end",
),
"<< /S /Chapter /Pg 3 0 R /K 0 >>".into(),
"<< /S /H /Pg 3 0 R /K << /Type /MCR /MCID 2 >> >>".into(),
"<< /S /H2 /Pg 4 0 R /ActualText (Override) /K 0 >>".into(),
"<< /S /H3 /Pg 4 0 R /K [1 15 0 R] >>".into(),
"<< /S /H1 /Pg 4 0 R /K 9 >>".into(),
]
}
#[test]
fn headings_are_found_with_their_levels_titles_and_pages() {
let doc = pdf(&headings_doc(), "");
let found: Vec<_> = headings(&doc).into_iter().map(|h| (h.title, h.level, h.page, h.obj)).collect();
assert_eq!(
found,
[
("Chapter One".to_string(), 1, 0, ref_(12)),
("Sec A".to_string(), 2, 0, ref_(13)),
("Override".to_string(), 2, 1, ref_(14)),
("AB".to_string(), 3, 1, ref_(15)),
]
);
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
objs[0] = "<< /Type /Catalog /Pages 2 0 R >>".into();
assert!(headings(&pdf(&objs, "")).is_empty());
}
+5
View File
@@ -268,6 +268,11 @@ impl Automation {
let (path, page) = (a.path("path")?, self.page(&a)?);
self.apply(&a, Edit::SetBookmarkPage { path, page })?
}
"bookmark_from_structure" => {
let mut out = self.apply(&a, Edit::BookmarksFromStructure)?;
out["bookmarks"] = json!(bookmark_tree(&self.doc(&a)?.info.outline, &[]));
out
}
"doc_protect" => self.doc_protect(&a)?,
"page_replace" => {
let pages = self.pages(&a, "pages")?;
+6
View File
@@ -239,6 +239,12 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.")
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])),
t(
"bookmark_from_structure",
"New bookmarks from structure",
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the bookmark tree. Undoable.",
)
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
json!({
"doc": doc(),
+35
View File
@@ -898,6 +898,41 @@ fn bookmarks_through_tools() {
assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details")));
}
#[test]
fn bookmarks_from_structure_through_tools() {
let dir = workdir("bookmarks-structure");
std::fs::write(
dir.join("tagged.pdf"),
b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /StructTreeRoot /K 6 0 R /RoleMap << /Heading /H1 >> >> endobj
6 0 obj << /S /Document /K [7 0 R << /S /Sect /K 8 0 R >>] >> endobj
7 0 obj << /S /Heading /Pg 3 0 R /ActualText (Report) >> endobj
8 0 obj << /S /H2 /Pg 4 0 R /Alt (Findings) >> endobj
trailer << /Root 1 0 R >>
%%EOF"
.as_slice(),
)
.unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "tagged.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "bookmark_from_structure", json!({ "doc": doc }));
let top = &r["bookmarks"][0];
assert_eq!(
(top["title"].as_str(), top["children"][0]["title"].as_str(), top["children"][0]["page"].as_u64()),
(Some("Untitled"), Some("Report"), Some(1))
);
assert_eq!(top["children"][0]["children"][0]["title"], "Findings");
assert_eq!(top["children"][0]["children"][0]["path"], json!([1, 1, 1]));
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from structure");
// An untagged document: an error that says why.
let plain = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert!(matches!(a.call("bookmark_from_structure", &json!({ "doc": plain })), Err(ToolError::Failed(m)) if m.contains("no tagged headings")));
}
#[test]
fn numbering_pages_through_tools() {
let dir = workdir("labels");
+1
View File
@@ -324,6 +324,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
+12
View File
@@ -773,6 +773,9 @@ pub enum Edit {
path: Vec<usize>,
page: usize,
},
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
NumberPages {
from: usize,
@@ -1133,6 +1136,7 @@ impl Edit {
Edit::DeleteBookmark { .. } => "Delete bookmark".into(),
Edit::MoveBookmark { .. } => "Move bookmark".into(),
Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(),
Edit::BookmarksFromStructure => "New bookmarks from structure".into(),
Edit::NumberPages { .. } => "Number pages".into(),
Edit::AddMeasurement(m) => format!("Measure {}", m.kind.name()),
Edit::SetMeasurementScale { .. } => "Set measurement scale".into(),
@@ -1267,6 +1271,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::DeleteBookmark { .. }
| Edit::MoveBookmark { .. }
| Edit::SetBookmarkPage { .. }
| Edit::BookmarksFromStructure
| Edit::NumberPages { .. } => {
if p.assemble() {
Ok(())
@@ -1474,6 +1479,13 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?;
}
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
.collect();
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
+45
View File
@@ -631,6 +631,51 @@ fn bookmark_edits_show_in_the_viewer_undo_and_save() {
assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]);
}
/// Two pages tagged with an H1 on the first and an H2 on the second.
fn tagged_headings_fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources << /Font << /F1 6 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Length 52 >>\nstream\n/H1 << /MCID 0 >> BDC BT /F1 12 Tf (Intro) Tj ET EMC\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Type /StructTreeRoot /K [8 0 R 9 0 R] >>",
"<< /S /H1 /Pg 3 0 R /K 0 >>",
"<< /S /H2 /Pg 4 0 R /ActualText (Details) >>",
];
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
}
#[test]
fn bookmarks_from_structure_nest_the_headings_and_undo() {
let mut s = Session::new();
let id = s.open("tagged.pdf", None, Arc::new(tagged_headings_fixture()), None).unwrap();
s.apply(id, Edit::BookmarksFromStructure).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Intro→1[Details→2]]"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from structure"));
s.undo(id).unwrap();
assert!(s.get(id).unwrap().info.outline.is_empty());
// Untagged: a clear error and nothing changes.
let (mut s, id) = session_with(2);
let err = s.apply(id, Edit::BookmarksFromStructure).unwrap_err();
assert_eq!(err.to_string(), "this document has no tagged headings to make bookmarks from");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
#[test]
fn number_pages_shows_in_the_viewer_and_undoes() {
let (mut s, id) = session_with(5);
+2 -1
View File
@@ -27,7 +27,8 @@ pub use dedupe::dedupe_resources;
pub use import::{SplitBy, combine, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges};
pub use labels::{LabelRange, LabelStyle, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use outline::{
Bookmark, OutlineError, add_bookmark, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page,
Bookmark, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmarks, delete_bookmark, move_bookmark, rename_bookmark,
set_bookmark_open, set_bookmark_page,
};
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
+70 -3
View File
@@ -36,6 +36,8 @@ pub enum OutlineError {
IntoItself,
#[error("the title must not be empty")]
EmptyTitle,
#[error("this document has no tagged headings to make bookmarks from")]
NoEntries,
#[error("{0}")]
Organize(#[from] OrganizeError),
#[error("{0}")]
@@ -191,9 +193,13 @@ fn recount(doc: &mut Document) -> Result<()> {
fn destination(doc: &Document, page: usize) -> Result<Object> {
let pages = walk(doc)?;
let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?;
// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for
// a new bookmark when the view has no specific position).
Ok(Object::Array(vec![Object::Ref(*p), Object::name("XYZ"), Object::Null, Object::Null, Object::Null]))
Ok(go_to(*p))
}
/// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for a
/// new bookmark when the view has no specific position).
fn go_to(page: ObjRef) -> Object {
Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, Object::Null])
}
/// Add a bookmark titled `title` that goes to `page` (0-based), as child `index` of
@@ -222,6 +228,67 @@ pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, tit
Ok(path)
}
/// One bookmark of a generated tree (New Bookmarks from Structure).
#[derive(Clone, Debug, PartialEq)]
pub struct OutlineEntry {
/// 1 = directly under the new parent; deeper levels nest under the last shallower entry.
pub level: u8,
pub title: String,
pub page: usize,
/// The structure element it stands for (`/SE`).
pub element: Option<ObjRef>,
}
/// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by
/// level, as Acrobat does for bookmarks made from structure; everything starts expanded.
/// Returns the parent's path.
pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[OutlineEntry]) -> Result<Vec<usize>> {
if entries.is_empty() {
return Err(OutlineError::NoEntries);
}
let pages = walk(doc)?;
let targets = entries
.iter()
.map(|e| pages.get(e.page).map(|p| p.0).ok_or(OrganizeError::NoSuchPage(e.page)))
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(PdfString::text(parent_title)));
let parent = doc.add(Object::Dict(d));
// (level, item, its children so far); the bottom is the new parent.
let mut stack: Vec<(u8, ObjRef, Vec<ObjRef>)> = vec![(0, parent, Vec::new())];
let mut done: Vec<(ObjRef, Vec<ObjRef>)> = Vec::new();
for (e, page) in entries.iter().zip(targets) {
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title)));
d.set(b"Dest".to_vec(), go_to(page));
if let Some(se) = e.element {
d.set(b"SE".to_vec(), Object::Ref(se));
}
let item = doc.add(Object::Dict(d));
while stack.len() > 1 && stack.last().is_some_and(|s| s.0 >= e.level) {
done.extend(stack.pop().map(|(_, r, kids)| (r, kids)));
}
if let Some(top) = stack.last_mut() {
top.2.push(item);
}
stack.push((e.level, item, Vec::new()));
}
done.extend(stack.into_iter().map(|(_, r, kids)| (r, kids)));
for (r, kids) in &done {
relink(doc, *r, kids)?;
if !kids.is_empty() && *r != parent {
put(doc, *r, b"Count", Some(Object::Int(1)))?;
}
}
let root = container(doc, &[])?;
let mut top = children_of(doc, root, &mut HashSet::from([root]));
top.insert(0, parent);
relink(doc, root, &top)?;
put(doc, parent, b"Count", Some(Object::Int(1)))?;
recount(doc)?;
Ok(vec![0])
}
/// Change a bookmark's title.
pub fn rename_bookmark(doc: &mut Document, path: &[usize], title: &str) -> Result<()> {
if title.trim().is_empty() {
+24
View File
@@ -735,6 +735,30 @@ fn bookmarks_add_rename_move_delete() {
assert_eq!(titles(&crate::bookmarks(&back)), ["Intro", "Results[Figure 1]"]);
}
#[test]
fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap();
let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element };
let se = ObjRef::new(4242, 0);
let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)];
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Untitled[One[Deep,Two],Next]", "Existing"]);
let b = crate::bookmarks(&d);
let root = d.get(d.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
assert_eq!((count_of(&d, root), count_of(&d, b[0].obj), count_of(&d, b[0].children[0].obj)), (Some(6), Some(4), Some(2)));
let one = d.get(b[0].children[0].obj).as_dict().cloned().unwrap();
assert_eq!(one.get(b"SE"), Some(&Object::Ref(se)));
let pages = crate::walk(&d).unwrap();
let next = d.get(b[0].children[1].obj).as_dict().cloned().unwrap();
assert_eq!(next.get(b"Dest").and_then(|x| x.as_array()).map(|a| a[0].clone()), Some(Object::Ref(pages[2].0)));
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &[]), Err(crate::OutlineError::NoEntries));
assert!(crate::add_bookmark_tree(&mut d, "Untitled", &[e(1, "Nowhere", 9, None)]).is_err());
assert_eq!(titles(&crate::bookmarks(&full_roundtrip(&d))), ["Untitled[One[Deep,Two],Next]", "Existing"]);
}
#[test]
fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() {
let mut d = doc_a();
+1
View File
@@ -173,6 +173,7 @@ impl PdfCraftApp {
}
}
"bookmark.add" => self.bookmark_action(crate::panels::BmAction::New),
"bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure),
"edit.undo" => self.undo(),
"edit.redo" => self.redo(),
"edit.find" => {
+4
View File
@@ -717,6 +717,10 @@ impl PdfCraftApp {
let at = parent[parent.len() - 1] + 1;
Edit::MoveBookmark { from: path, to_parent: grand, index: at }
}
A::FromStructure => {
self.right = Some(crate::RightPanel::Bookmarks);
Edit::BookmarksFromStructure
}
};
self.apply_edit(edit);
}
+1
View File
@@ -278,6 +278,7 @@
Security properties… خصائص الأمان…
Organize pages تنظيم الصفحات
New bookmark إشارة مرجعية جديدة
New bookmarks from structure إشارات مرجعية جديدة من البنية
Rotate pages clockwise تدوير الصفحات مع عقارب الساعة
Rotate pages counterclockwise تدوير الصفحات عكس عقارب الساعة
Delete pages حذف الصفحات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1739,6 +1739,7 @@
Security properties… Свойства на защитата…
Organize pages Организиране на страници
New bookmark Нова отметка
New bookmarks from structure Нови отметки от структурата
Rotate pages clockwise Завърти страниците по часовниковата стрелка
Rotate pages counterclockwise Завърти страниците обратно на часовниковата стрелка
Delete pages Изтрий страници
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -80,6 +80,7 @@
Signatures panel Panel podpisů
Organize pages Uspořádat stránky
New bookmark Nová záložka
New bookmarks from structure Nové záložky ze struktury
Rotate pages clockwise Otočit stránky po směru hodinových ručiček
Rotate pages counterclockwise Otočit stránky proti směru hodinových ručiček
Delete pages Odstranit stránky
Can't render this file because it contains an unexpected character in line 7 and column 81.
+1
View File
@@ -250,6 +250,7 @@
Security properties… Sicherheitseigenschaften…
Organize pages Seiten organisieren
New bookmark Neues Lesezeichen
New bookmarks from structure Neue Lesezeichen aus der Struktur
Rotate pages clockwise Seiten im Uhrzeigersinn drehen
Rotate pages counterclockwise Seiten gegen den Uhrzeigersinn drehen
Delete pages Seiten löschen
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -271,6 +271,7 @@
Security properties… Propiedades de seguridad…
Organize pages Organizar páginas
New bookmark Nuevo marcador
New bookmarks from structure Nuevos marcadores a partir de la estructura
Rotate pages clockwise Girar páginas a la derecha
Rotate pages counterclockwise Girar páginas a la izquierda
Delete pages Eliminar páginas
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -250,6 +250,7 @@
Security properties… Propriétés de protection…
Organize pages Organiser les pages
New bookmark Nouveau signet
New bookmarks from structure Nouveaux signets à partir de la structure
Rotate pages clockwise Pivoter les pages vers la droite
Rotate pages counterclockwise Pivoter les pages vers la gauche
Delete pages Supprimer des pages
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -248,6 +248,7 @@
Security properties… Biztonság tulajdonságai…
Organize pages Oldalak rendezése
New bookmark Új könyvjelző
New bookmarks from structure Új könyvjelzők a szerkezetből
Rotate pages clockwise Oldalak forgatása az óramutató járásával megegyezően
Rotate pages counterclockwise Oldalak forgatása az óramutató járásával ellentétesen
Delete pages Oldalak törlése
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -274,6 +274,7 @@
Security properties… Proprietà di sicurezza…
Organize pages Organizza pagine
New bookmark Nuovo segnalibro
New bookmarks from structure Nuovi segnalibri dalla struttura
Rotate pages clockwise Ruota pagine in senso orario
Rotate pages counterclockwise Ruota pagine in senso antiorario
Delete pages Elimina pagine
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -277,6 +277,7 @@
Security properties… セキュリティのプロパティ…
Organize pages ページを整理
New bookmark しおりを追加
New bookmarks from structure 構造からしおりを作成
Rotate pages clockwise ページを時計回りに回転
Rotate pages counterclockwise ページを反時計回りに回転
Delete pages ページを削除
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1613,6 +1613,7 @@
Security properties… Propriedades de segurança…
Organize pages Organizar páginas
New bookmark Novo marcador
New bookmarks from structure Novos marcadores a partir da estrutura
Rotate pages clockwise Girar páginas em sentido horário
Rotate pages counterclockwise Girar páginas em sentido anti-horário
Delete pages Excluir páginas
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -271,6 +271,7 @@
Security properties… Свойства безопасности…
Organize pages Организовать страницы
New bookmark Новая закладка
New bookmarks from structure Новые закладки из структуры
Rotate pages clockwise Повернуть страницы по часовой стрелке
Rotate pages counterclockwise Повернуть страницы против часовой стрелки
Delete pages Удалить страницы
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -254,6 +254,7 @@ comment menu Edit సవరించు
Security properties… భద్రతా లక్షణాలు…
Organize pages పేజీలను నిర్వహించు
New bookmark కొత్త బుక్‌మార్క్
New bookmarks from structure నిర్మాణం నుండి కొత్త బుక్‌మార్క్‌లు
Rotate pages clockwise పేజీలను సవ్యదిశలో తిప్పు
Rotate pages counterclockwise పేజీలను అపసవ్యదిశలో తిప్పు
Delete pages పేజీలను తొలగించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -276,6 +276,7 @@
Security properties… Властивості захисту…
Organize pages Упорядкувати сторінки
New bookmark Нова закладка
New bookmarks from structure Нові закладки зі структури
Rotate pages clockwise Повернути сторінки за годинниковою стрілкою
Rotate pages counterclockwise Повернути сторінки проти годинникової стрілки
Delete pages Видалити сторінки
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1746,6 +1746,7 @@
Security properties… 安全性属性…
Organize pages 组织页面
New bookmark 新建书签
New bookmarks from structure 从结构新建书签
Rotate pages clockwise 顺时针旋转页面
Rotate pages counterclockwise 逆时针旋转页面
Delete pages 删除页面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1694,6 +1694,7 @@
Security properties… 安全性內容…
Organize pages 整理頁面
New bookmark 新增書籤
New bookmarks from structure 從結構新增書籤
Rotate pages clockwise 順時針旋轉頁面
Rotate pages counterclockwise 逆時針旋轉頁面
Delete pages 刪除頁面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+17 -9
View File
@@ -464,18 +464,24 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
};
view.comments.search_focus = true;
}
if panel == RightPanel::Bookmarks && !info.outline.is_empty() {
if panel == RightPanel::Bookmarks && (bm_editable || !info.outline.is_empty()) {
let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Bookmark options"));
egui::Popup::menu(&more).show(|ui| {
ui.set_min_width(200.0);
for (levels, label) in [
(usize::MAX, tl!("Expand all bookmarks")),
(1, tl!("Expand top-level bookmarks")),
(0, tl!("Collapse all bookmarks")),
] {
if ui.button(label).clicked() {
bm_expand = Some(levels);
ui.close();
if bm_editable && ui.button(tl!("New bookmarks from structure")).clicked() {
bm_action = Some(BmAction::FromStructure);
ui.close();
}
if !info.outline.is_empty() {
for (levels, label) in [
(usize::MAX, tl!("Expand all bookmarks")),
(1, tl!("Expand top-level bookmarks")),
(0, tl!("Collapse all bookmarks")),
] {
if ui.button(label).clicked() {
bm_expand = Some(levels);
ui.close();
}
}
}
});
@@ -718,6 +724,8 @@ pub enum BmAction {
Indent(Vec<usize>),
/// Move it out to follow its parent.
Outdent(Vec<usize>),
/// Bookmarks from the tagged headings, under a new first "Untitled" bookmark.
FromStructure,
}
/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes.
+30
View File
@@ -214,6 +214,36 @@ fn bookmark_search_is_per_document() {
h.get_by_label("First document");
}
#[test]
fn new_bookmarks_from_structure_nest_the_tagged_headings() {
const TAGGED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /StructTreeRoot /K [6 0 R 7 0 R] >> endobj
6 0 obj << /S /H1 /Pg 3 0 R /ActualText (Overview) >> endobj
7 0 obj << /S /H2 /Pg 4 0 R /ActualText (Method) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut h = harness();
h.state_mut().open_bytes("tagged.pdf", None, TAGGED.to_vec()).unwrap();
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("New bookmarks from structure").click();
h.run_steps(3);
let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id;
let titles: Vec<String> = h.state().session.get(id).unwrap().info.outline.iter().map(|o| o.title.clone()).collect();
assert_eq!(titles, ["Untitled"]);
h.get_by_label("Overview");
h.get_by_label("Method");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from structure"));
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
h.render().unwrap().save(std::path::Path::new(&dir).join("bookmarks-from-structure.png")).unwrap();
}
}
#[test]
fn expanding_and_collapsing_all_bookmarks() {
use pdfcraft_engine::Edit;
+1 -1
View File
@@ -24,7 +24,7 @@ Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com
## Arabic and right-to-left text
The Arabic catalog has 2,136 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months.
The Arabic catalog has 2,137 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months.
egui joins the letters of an Arabic word and runs them right to left, but it places the words of a line left to right in the order they are written. So catalog entries are written in logical (typing) order, and `i18n` puts each one into display order when the catalog loads (`bidi::display_rtl`): the line reads right to left, Latin words and numbers inside it read left to right, `{name}` placeholders and keyboard shortcuts stay whole, spaces at either end stay where the code concatenates, and a short caption keeps its colon on the right, beside the widget it captions. Lookups (`tl!`, `tr`, `trn`) therefore return display-order text for Arabic; `i18n::fmt` inserts values as they are. The command search matches a query of several Arabic words word by word.
+5 -5
View File
@@ -3259,11 +3259,11 @@ title = "Generate bookmarks from headings or tags"
area = "D"
tier = "P1"
milestone = "M4"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["bookmark.from_structure"]
tools = ["bookmark_from_structure"]
evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"]
notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it"
[[feature]]
id = "organize.attachments-add"