mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
38 files changed
+588
-39
No files matched your search
+15
-15
@@ -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
@@ -3565,6 +3565,7 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
+3
-2
@@ -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%).
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user