From 108fdc108ca7ee9ce6c7d0d6c70bed96865d0077 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:00:02 -0400 Subject: [PATCH] more progress, roadmap, wip --- CLAUDE.md | 2 +- Cargo.lock | 335 +- Cargo.toml | 18 +- NOTICE | 4 + README.md | 72 +- ROADMAP.md | 80 + apps/printcraft-cli/Cargo.toml | 18 + apps/printcraft-cli/src/main.rs | 288 + apps/printcraft-web/Cargo.toml | 20 + apps/printcraft-web/index.html | 17 + apps/printcraft-web/src/main.rs | 77 + apps/printcraft/Cargo.toml | 2 +- apps/printcraft/src/main.rs | 5 +- assets/demo/README.md | 78 + assets/demo/showcase.html | 70 +- crates/cos/Cargo.toml | 17 + crates/cos/examples/cosinfo.rs | 17 + crates/cos/src/document.rs | 708 ++ crates/cos/src/lib.rs | 38 + crates/cos/src/object.rs | 386 + crates/cos/src/parser.rs | 514 ++ crates/cos/src/writer.rs | 386 + crates/cos/tests/corpus.rs | 101 + crates/engine/Cargo.toml | 3 + crates/engine/src/lib.rs | 324 +- crates/engine/src/tests.rs | 191 + crates/filters/Cargo.toml | 19 + crates/filters/README.md | 50 + crates/filters/fuzz/.gitignore | 5 + crates/filters/fuzz/Cargo.toml | 24 + crates/filters/fuzz/fuzz_targets/decode.rs | 32 + crates/filters/src/ascii85.rs | 156 + crates/filters/src/asciihex.rs | 106 + crates/filters/src/flate.rs | 166 + crates/filters/src/lib.rs | 325 + crates/filters/src/lzw.rs | 314 + crates/filters/src/predictor.rs | 332 + crates/filters/src/runlength.rs | 117 + .../tests/proptests.proptest-regressions | 7 + crates/filters/tests/proptests.rs | 150 + crates/organize/Cargo.toml | 16 + crates/organize/src/lib.rs | 214 + crates/organize/src/tests.rs | 187 + crates/render/Cargo.toml | 5 + crates/render/src/inspect.rs | 236 +- crates/render/src/lib.rs | 15 +- crates/render/src/raster.rs | 430 +- crates/render/src/text.rs | 512 ++ crates/ui-egui/Cargo.toml | 15 +- crates/ui-egui/examples/shot.rs | 64 + crates/ui-egui/src/canvas.rs | 793 +- crates/ui-egui/src/chrome.rs | 62 +- crates/ui-egui/src/dialogs.rs | 260 +- crates/ui-egui/src/editing.rs | 263 + crates/ui-egui/src/home.rs | 3 + crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/lib.rs | 260 +- crates/ui-egui/src/panels.rs | 185 +- crates/ui-egui/src/widgets.rs | 4 + crates/ui-egui/tests/editing.rs | 303 + crates/ui-egui/tests/ui.rs | 214 + docs/images/printcraft-organize.png | Bin 0 -> 707104 bytes docs/images/printcraft-viewer.png | Bin 0 -> 817648 bytes vendor/README.md | 14 + vendor/hayro-interpret/.cargo-ok | 1 + vendor/hayro-interpret/.cargo_vcs_info.json | 6 + vendor/hayro-interpret/Cargo.lock | 529 ++ vendor/hayro-interpret/Cargo.toml | 139 + vendor/hayro-interpret/Cargo.toml.orig | 38 + vendor/hayro-interpret/LICENSE-APACHE | 177 + vendor/hayro-interpret/LICENSE-MIT | 7 + vendor/hayro-interpret/README.md | 41 + .../assets/CGATS001Compat-v2-micro.icc | Bin 0 -> 8464 bytes .../hayro-interpret/assets/CGATS_LICENSE.txt | 116 + .../hayro-interpret/assets/FoxitDingbats.pfb | Bin 0 -> 29513 bytes vendor/hayro-interpret/assets/FoxitFixed.pfb | Bin 0 -> 17597 bytes .../hayro-interpret/assets/FoxitFixedBold.pfb | Bin 0 -> 18055 bytes .../assets/FoxitFixedBoldItalic.pfb | Bin 0 -> 19151 bytes .../assets/FoxitFixedItalic.pfb | Bin 0 -> 18746 bytes vendor/hayro-interpret/assets/FoxitSans.pfb | Bin 0 -> 15025 bytes .../hayro-interpret/assets/FoxitSansBold.pfb | Bin 0 -> 16344 bytes .../assets/FoxitSansBoldItalic.pfb | Bin 0 -> 16418 bytes .../assets/FoxitSansItalic.pfb | Bin 0 -> 16339 bytes vendor/hayro-interpret/assets/FoxitSerif.pfb | Bin 0 -> 19469 bytes .../hayro-interpret/assets/FoxitSerifBold.pfb | Bin 0 -> 19395 bytes .../assets/FoxitSerifBoldItalic.pfb | Bin 0 -> 20733 bytes .../assets/FoxitSerifItalic.pfb | Bin 0 -> 21227 bytes vendor/hayro-interpret/assets/FoxitSymbol.pfb | Bin 0 -> 16729 bytes vendor/hayro-interpret/assets/LAB.icc | Bin 0 -> 484 bytes vendor/hayro-interpret/assets/LICENSE_FOXIT | 27 + vendor/hayro-interpret/assets/README.md | 15 + .../hayro-interpret/examples/extract_html.rs | 122 + .../examples/extract_images.rs | 147 + vendor/hayro-interpret/src/cache.rs | 161 + vendor/hayro-interpret/src/color.rs | 1124 +++ vendor/hayro-interpret/src/context.rs | 373 + vendor/hayro-interpret/src/convert.rs | 30 + vendor/hayro-interpret/src/device.rs | 87 + vendor/hayro-interpret/src/encode.rs | 426 + vendor/hayro-interpret/src/font/blob.rs | 335 + vendor/hayro-interpret/src/font/cid.rs | 592 ++ .../src/font/generated/glyph_names.rs | 8119 +++++++++++++++++ .../src/font/generated/mac_expert.rs | 174 + .../src/font/generated/mac_os_roman.rs | 48 + .../src/font/generated/mac_roman.rs | 432 + .../src/font/generated/metrics.rs | 4215 +++++++++ .../hayro-interpret/src/font/generated/mod.rs | 9 + .../src/font/generated/standard.rs | 157 + .../src/font/generated/symbol.rs | 197 + .../src/font/generated/win_ansi.rs | 230 + .../src/font/generated/zapf_dings.rs | 210 + .../src/font/glyph_simulator.rs | 53 + vendor/hayro-interpret/src/font/mod.rs | 683 ++ vendor/hayro-interpret/src/font/outline.rs | 146 + .../hayro-interpret/src/font/standard_font.rs | 497 + vendor/hayro-interpret/src/font/true_type.rs | 486 + vendor/hayro-interpret/src/font/type1.rs | 341 + vendor/hayro-interpret/src/font/type3.rs | 260 + vendor/hayro-interpret/src/function/mod.rs | 105 + vendor/hayro-interpret/src/function/type0.rs | 302 + vendor/hayro-interpret/src/function/type2.rs | 148 + vendor/hayro-interpret/src/function/type3.rs | 135 + vendor/hayro-interpret/src/function/type4.rs | 910 ++ vendor/hayro-interpret/src/interpret/mod.rs | 755 ++ vendor/hayro-interpret/src/interpret/path.rs | 163 + vendor/hayro-interpret/src/interpret/state.rs | 440 + vendor/hayro-interpret/src/interpret/text.rs | 188 + vendor/hayro-interpret/src/lib.rs | 61 + vendor/hayro-interpret/src/log.rs | 27 + vendor/hayro-interpret/src/ocg.rs | 179 + vendor/hayro-interpret/src/pattern.rs | 343 + vendor/hayro-interpret/src/shading.rs | 1032 +++ vendor/hayro-interpret/src/soft_mask.rs | 190 + vendor/hayro-interpret/src/types.rs | 366 + vendor/hayro-interpret/src/util.rs | 183 + vendor/hayro-interpret/src/x_object.rs | 970 ++ vendor/hayro/.cargo-ok | 1 + vendor/hayro/.cargo_vcs_info.json | 6 + vendor/hayro/Cargo.lock | 665 ++ vendor/hayro/Cargo.toml | 118 + vendor/hayro/Cargo.toml.orig | 31 + vendor/hayro/LICENSE-APACHE | 177 + vendor/hayro/LICENSE-MIT | 7 + vendor/hayro/README.md | 46 + vendor/hayro/examples/render.rs | 149 + vendor/hayro/src/lib.rs | 224 + vendor/hayro/src/renderer.rs | 1141 +++ xtask/Cargo.toml | 1 + xtask/baselines/pdfjs.json | 22 + xtask/src/demo_pdf/annots.rs | 61 +- xtask/src/demo_pdf/chrome.rs | 73 +- xtask/src/demo_pdf/form.rs | 32 +- xtask/src/demo_pdf/mod.rs | 2 +- xtask/src/demo_pdf/pdf.rs | 47 +- xtask/src/demo_pdf/postprocess.rs | 176 +- xtask/src/gates.rs | 269 + xtask/src/layers.rs | 336 + xtask/src/main.rs | 16 +- 158 files changed, 39957 insertions(+), 521 deletions(-) create mode 100644 ROADMAP.md create mode 100644 apps/printcraft-cli/Cargo.toml create mode 100644 apps/printcraft-cli/src/main.rs create mode 100644 apps/printcraft-web/Cargo.toml create mode 100644 apps/printcraft-web/index.html create mode 100644 apps/printcraft-web/src/main.rs create mode 100644 assets/demo/README.md create mode 100644 crates/cos/Cargo.toml create mode 100644 crates/cos/examples/cosinfo.rs create mode 100644 crates/cos/src/document.rs create mode 100644 crates/cos/src/lib.rs create mode 100644 crates/cos/src/object.rs create mode 100644 crates/cos/src/parser.rs create mode 100644 crates/cos/src/writer.rs create mode 100644 crates/cos/tests/corpus.rs create mode 100644 crates/engine/src/tests.rs create mode 100644 crates/filters/Cargo.toml create mode 100644 crates/filters/README.md create mode 100644 crates/filters/fuzz/.gitignore create mode 100644 crates/filters/fuzz/Cargo.toml create mode 100644 crates/filters/fuzz/fuzz_targets/decode.rs create mode 100644 crates/filters/src/ascii85.rs create mode 100644 crates/filters/src/asciihex.rs create mode 100644 crates/filters/src/flate.rs create mode 100644 crates/filters/src/lib.rs create mode 100644 crates/filters/src/lzw.rs create mode 100644 crates/filters/src/predictor.rs create mode 100644 crates/filters/src/runlength.rs create mode 100644 crates/filters/tests/proptests.proptest-regressions create mode 100644 crates/filters/tests/proptests.rs create mode 100644 crates/organize/Cargo.toml create mode 100644 crates/organize/src/lib.rs create mode 100644 crates/organize/src/tests.rs create mode 100644 crates/render/src/text.rs create mode 100644 crates/ui-egui/examples/shot.rs create mode 100644 crates/ui-egui/src/editing.rs create mode 100644 crates/ui-egui/tests/editing.rs create mode 100644 crates/ui-egui/tests/ui.rs create mode 100644 docs/images/printcraft-organize.png create mode 100644 docs/images/printcraft-viewer.png create mode 100644 vendor/README.md create mode 100644 vendor/hayro-interpret/.cargo-ok create mode 100644 vendor/hayro-interpret/.cargo_vcs_info.json create mode 100644 vendor/hayro-interpret/Cargo.lock create mode 100644 vendor/hayro-interpret/Cargo.toml create mode 100644 vendor/hayro-interpret/Cargo.toml.orig create mode 100644 vendor/hayro-interpret/LICENSE-APACHE create mode 100644 vendor/hayro-interpret/LICENSE-MIT create mode 100644 vendor/hayro-interpret/README.md create mode 100644 vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc create mode 100644 vendor/hayro-interpret/assets/CGATS_LICENSE.txt create mode 100644 vendor/hayro-interpret/assets/FoxitDingbats.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitFixed.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitFixedBold.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitFixedItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSans.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSansBold.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSansItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSerif.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSerifBold.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSerifItalic.pfb create mode 100644 vendor/hayro-interpret/assets/FoxitSymbol.pfb create mode 100644 vendor/hayro-interpret/assets/LAB.icc create mode 100644 vendor/hayro-interpret/assets/LICENSE_FOXIT create mode 100644 vendor/hayro-interpret/assets/README.md create mode 100644 vendor/hayro-interpret/examples/extract_html.rs create mode 100644 vendor/hayro-interpret/examples/extract_images.rs create mode 100644 vendor/hayro-interpret/src/cache.rs create mode 100644 vendor/hayro-interpret/src/color.rs create mode 100644 vendor/hayro-interpret/src/context.rs create mode 100644 vendor/hayro-interpret/src/convert.rs create mode 100644 vendor/hayro-interpret/src/device.rs create mode 100644 vendor/hayro-interpret/src/encode.rs create mode 100644 vendor/hayro-interpret/src/font/blob.rs create mode 100644 vendor/hayro-interpret/src/font/cid.rs create mode 100644 vendor/hayro-interpret/src/font/generated/glyph_names.rs create mode 100644 vendor/hayro-interpret/src/font/generated/mac_expert.rs create mode 100644 vendor/hayro-interpret/src/font/generated/mac_os_roman.rs create mode 100644 vendor/hayro-interpret/src/font/generated/mac_roman.rs create mode 100644 vendor/hayro-interpret/src/font/generated/metrics.rs create mode 100644 vendor/hayro-interpret/src/font/generated/mod.rs create mode 100644 vendor/hayro-interpret/src/font/generated/standard.rs create mode 100644 vendor/hayro-interpret/src/font/generated/symbol.rs create mode 100644 vendor/hayro-interpret/src/font/generated/win_ansi.rs create mode 100644 vendor/hayro-interpret/src/font/generated/zapf_dings.rs create mode 100644 vendor/hayro-interpret/src/font/glyph_simulator.rs create mode 100644 vendor/hayro-interpret/src/font/mod.rs create mode 100644 vendor/hayro-interpret/src/font/outline.rs create mode 100644 vendor/hayro-interpret/src/font/standard_font.rs create mode 100644 vendor/hayro-interpret/src/font/true_type.rs create mode 100644 vendor/hayro-interpret/src/font/type1.rs create mode 100644 vendor/hayro-interpret/src/font/type3.rs create mode 100644 vendor/hayro-interpret/src/function/mod.rs create mode 100644 vendor/hayro-interpret/src/function/type0.rs create mode 100644 vendor/hayro-interpret/src/function/type2.rs create mode 100644 vendor/hayro-interpret/src/function/type3.rs create mode 100644 vendor/hayro-interpret/src/function/type4.rs create mode 100644 vendor/hayro-interpret/src/interpret/mod.rs create mode 100644 vendor/hayro-interpret/src/interpret/path.rs create mode 100644 vendor/hayro-interpret/src/interpret/state.rs create mode 100644 vendor/hayro-interpret/src/interpret/text.rs create mode 100644 vendor/hayro-interpret/src/lib.rs create mode 100644 vendor/hayro-interpret/src/log.rs create mode 100644 vendor/hayro-interpret/src/ocg.rs create mode 100644 vendor/hayro-interpret/src/pattern.rs create mode 100644 vendor/hayro-interpret/src/shading.rs create mode 100644 vendor/hayro-interpret/src/soft_mask.rs create mode 100644 vendor/hayro-interpret/src/types.rs create mode 100644 vendor/hayro-interpret/src/util.rs create mode 100644 vendor/hayro-interpret/src/x_object.rs create mode 100644 vendor/hayro/.cargo-ok create mode 100644 vendor/hayro/.cargo_vcs_info.json create mode 100644 vendor/hayro/Cargo.lock create mode 100644 vendor/hayro/Cargo.toml create mode 100644 vendor/hayro/Cargo.toml.orig create mode 100644 vendor/hayro/LICENSE-APACHE create mode 100644 vendor/hayro/LICENSE-MIT create mode 100644 vendor/hayro/README.md create mode 100644 vendor/hayro/examples/render.rs create mode 100644 vendor/hayro/src/lib.rs create mode 100644 vendor/hayro/src/renderer.rs create mode 100644 xtask/baselines/pdfjs.json create mode 100644 xtask/src/gates.rs create mode 100644 xtask/src/layers.rs diff --git a/CLAUDE.md b/CLAUDE.md index e0fd683e8..59e773985 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ PrintCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows and Linux, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions. ## Start every session here -1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. +1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` holds the milestone estimates and progress; update its table and log at the end of every session. 2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching. 3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's. diff --git a/Cargo.lock b/Cargo.lock index 497421e43..be12b15bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -141,6 +143,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -439,15 +442,30 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -465,6 +483,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -675,7 +696,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -951,6 +972,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -966,6 +988,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "glow", "glutin", "glutin-winit", "image", @@ -979,6 +1002,8 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "ron", + "serde", "static_assertions", "wasm-bindgen", "web-sys", @@ -1003,6 +1028,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -1044,6 +1071,7 @@ dependencies = [ "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", + "serde", "smithay-clipboard", "web-time", "winit", @@ -1078,6 +1106,24 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "pollster 1.0.1", + "serde", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.18.0" @@ -1091,6 +1137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1154,6 +1201,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -1172,6 +1230,7 @@ dependencies = [ "parking_lot", "profiling", "self_cell", + "serde", "skrifa 0.44.0", "smallvec", "unicode-general-category", @@ -1286,6 +1345,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -1308,6 +1373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1421,9 +1487,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1618,8 +1686,6 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" dependencies = [ "bytemuck", "hayro-interpret", @@ -1648,8 +1714,6 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -1901,6 +1965,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -2017,7 +2091,7 @@ dependencies = [ "log", "md-5", "nom", - "rand", + "rand 0.10.3", "rangemap", "rayon", "sha2", @@ -2103,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -2789,6 +2863,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -2804,23 +2887,66 @@ dependencies = [ "printcraft-ui-egui", ] +[[package]] +name = "printcraft-cli" +version = "0.1.0" +dependencies = [ + "printcraft-engine", + "printcraft-render", + "serde_json", +] + +[[package]] +name = "printcraft-cos" +version = "0.1.0" +dependencies = [ + "hayro-syntax", + "printcraft-filters", + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-engine" version = "0.1.0" dependencies = [ + "printcraft-cos", + "printcraft-organize", "printcraft-render", + "thiserror 2.0.21", +] + +[[package]] +name = "printcraft-filters" +version = "0.1.0" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", + "weezl 0.2.1", ] [[package]] name = "printcraft-geom" version = "0.1.0" +[[package]] +name = "printcraft-organize" +version = "0.1.0" +dependencies = [ + "hayro-syntax", + "printcraft-cos", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-render" version = "0.1.0" dependencies = [ + "getrandom 0.4.3", "hayro", "hayro-syntax", + "kurbo 0.13.1", "log", "lopdf", "thiserror 2.0.21", @@ -2833,10 +2959,30 @@ dependencies = [ "eframe", "egui", "egui_extras", + "egui_kittest", + "image", + "js-sys", + "log", "printcraft-engine", "printcraft-render", "rfd", "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "printcraft-web" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "js-sys", + "printcraft-ui-egui", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -2863,12 +3009,37 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -2905,6 +3076,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.3" @@ -2913,7 +3094,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -2922,6 +3122,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3020,6 +3229,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3076,6 +3291,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3135,6 +3364,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3198,6 +3439,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3209,6 +3463,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.11.0" @@ -3313,6 +3576,9 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -3541,7 +3807,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl 0.1.12", "zune-jpeg", ] @@ -3587,6 +3853,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3658,6 +3937,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -3675,6 +3960,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3833,6 +4124,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4112,8 +4412,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -4185,7 +4485,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", @@ -4684,6 +4984,7 @@ dependencies = [ "anyhow", "lopdf", "png 0.18.1", + "serde_json", ] [[package]] @@ -4862,6 +5163,12 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 9d150313c..6129bd2fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] resolver = "3" members = ["crates/*", "apps/*", "xtask"] -default-members = ["crates/*", "apps/printcraft", "xtask"] +exclude = ["vendor"] +default-members = ["crates/*", "apps/printcraft", "apps/printcraft-cli", "xtask"] [workspace.package] version = "0.1.0" @@ -13,6 +14,9 @@ repository = "https://github.com/storytold/printcraft" [workspace.dependencies] # internal printcraft-geom = { path = "crates/geom" } +printcraft-filters = { path = "crates/filters" } +printcraft-cos = { path = "crates/cos" } +printcraft-organize = { path = "crates/organize" } printcraft-render = { path = "crates/render" } printcraft-engine = { path = "crates/engine" } printcraft-ui-egui = { path = "crates/ui-egui" } @@ -21,13 +25,18 @@ printcraft-ui-egui = { path = "crates/ui-egui" } hayro = "0.7.1" hayro-syntax = "0.7.2" lopdf = "0.45" +flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } +weezl = "0.2" # foundation thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" -kurbo = "0.12" +kurbo = "0.13" + +# testing +proptest = "1" # ui egui = "0.36" @@ -52,3 +61,8 @@ opt-level = 1 [profile.release] lto = "thin" codegen-units = 1 + +# Vendored, patched third-party crates (see vendor/README.md). Each patch is meant to go upstream. +[patch.crates-io] +hayro-interpret = { path = "vendor/hayro-interpret" } +hayro = { path = "vendor/hayro" } diff --git a/NOTICE b/NOTICE index 1a98ff85f..3c736d0ff 100644 --- a/NOTICE +++ b/NOTICE @@ -13,3 +13,7 @@ This product bundles or depends on third-party material under the following lice Asset licences are not visible to cargo-deny; keep this file current whenever assets or asset-bundling dependencies change (plan/adr/0002, 0006). + +Vendored (vendor/, patched; see vendor/README.md): +- hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). +- hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). diff --git a/README.md b/README.md index b13535fc7..4c8bbbd93 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,78 @@ PrintCraft From the artcraft team -A clean-room, open-source PDF application written in Rust, aiming at Adobe Acrobat Pro parity. It runs natively on macOS, Windows and Linux, and in the browser via WebAssembly. It is a sibling project of PhotoCraft. +A clean-room, open-source PDF application written in Rust, aiming at Adobe Acrobat Pro parity. It runs natively on macOS, Windows and Linux, and in the browser via WebAssembly. -**Status:** early. The viewer slice works; the core PDF object layer (M1) is next. The plan lives in `plan/` (local) and the rules for contributors and agents are in `CLAUDE.md`. +

+ PrintCraft showing the typography showcase PDF, with the All tools panel on the left and threaded comments on the right +

+ +- **Engine first.** Parsing, rendering and editing live in library crates. The egui interface is one swappable crate on top. +- **Faithful to the file.** The PDF object graph is the document model. Unknown data is preserved. Saves are incremental: the original bytes are kept and your edits are appended. +- **Tested against the real world.** The pdf.js test corpus (983 files) is opened, rendered and round-tripped on every change, and qpdf and poppler are used as independent checks. + +

+ The Organize pages view: a grid of page thumbnails with rotate, delete, insert and move actions +

+ +## Status + +**Early.** Work so far: +- **Viewer:** rendering, text find, select and copy, bookmarks, comments, layers, attachments and form-field highlighting, on desktop and the web. +- **Editing:** + - Page organizing: rotate, delete, insert and reorder pages. + - Document properties. + - Undo and redo. + - Save and Save As. + - A prompt to save unsaved changes before closing. + +See [ROADMAP.md](ROADMAP.md) for the milestones, progress and estimates to full parity. ```sh cargo run -p printcraft -- some.pdf # desktop app cargo xtask demo-pdf # build dist/demo/printcraft-showcase.pdf (needs Chrome) -cargo run -p printcraft -- dist/demo/printcraft-showcase.pdf --panel bookmarks +cargo run -p printcraft -- dist/demo/printcraft-showcase.pdf --panel comments +cargo run -p printcraft-cli -- edit in.pdf --rotate 1:90 --delete 3 --title "Report" --out out.pdf ``` -Licence: MIT OR Apache-2.0 (proposed). Third-party material is listed in `NOTICE`. +The rules for contributors and agents are in [CLAUDE.md](CLAUDE.md). + +## The Craft family + +Open-source, clean-room, pure-Rust creative applications. Each is native on macOS, Windows and Linux, runs on the web, and can be driven by agents. + + + + + + + + + + + + + + + + +
+ PhotoCraft
+ Image editing +
+ DrawCraft
+ Vector illustration +
+ FilmCraft
+ Video editing +
+ LightCraft
+ Photo library & raw +
+ PrintCraft
+ PDF documents +
Layered raster editor with byte-exact PSD/PSB round trips and 13 formats at 8/16/32-bit. A Photoshop-class workflow.Vector illustration with the Illustrator-style tools, panels, menus and shortcuts people already know.Non-linear video editor with frame-exact timing and its own codecs. Aims at Premiere Pro parity.Photo library and non-destructive raw developer: its own raw decoders, a wide-gamut float pipeline and a local-first catalog.View, organize, annotate, fill, sign and edit PDFs. Aims at Acrobat Pro parity. You are here.
+ +## Licence + +MIT OR Apache-2.0 (proposed). Third-party material is listed in [NOTICE](NOTICE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..9293b08f1 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,80 @@ +# PrintCraft roadmap + +The milestones, current progress and time estimates to Acrobat Pro feature parity. Keep this up to date: +- **Every session:** update the progress column and add a line to the log. +- **Every milestone:** re-estimate. + +Detailed task lists and acceptance tests are in `plan/execution-plan.md` (local-only). This file is the public summary. + +**What "parity" means here:** the offline feature set of Acrobat Pro, milestones M0–M14. It excludes Adobe's cloud services (Document Cloud storage, Adobe Sign, the Adobe AI Assistant). Those have no clean-room equivalent; PrintCraft's alternatives are local-first, plus opt-in providers (M13). + +## Estimate summary + +The unit is **wall-clock hours of agent work**: Claude Opus-class models coding continuously, with a person reviewing at milestone boundaries. Time spent waiting for that review is not included. + +| Scenario | Hours to parity (M0–M14) | Continuous calendar time | +|---|---|---| +| One agent, 24/7 | **2,000–4,000 h** | ≈ 3–6 months | +| 3–5 agents in parallel on separate crates after M4 | **800–1,500 h** | ≈ 5–9 weeks | +| Realistic, with human review, integration and pauses | — | ≈ 4–9 months (see `plan/execution-plan.md` §9) | + +**How these numbers are built:** +- **Size:** about 450–700k lines of Rust at parity, including tests. The repo has about 15k today. +- **Rate:** so far, about 1.0–1.5k lines of *kept, tested* code per agent-hour. That includes debugging against the oracles (qpdf, poppler, pdf.js corpus). +- **Why the total is 3–6× the raw typing time:** the hard work is not the line count. Most of the time goes into fidelity work: text editing and reflow, font embedding, redaction that really removes content, signature validation, PDF/A and PDF/UA, XFA, OCR and web performance. Each of these needs repeated oracle checks and visual comparison. +- **Parallelism is limited early:** M0–M4 are mostly sequential, because the core crates must settle first. Extra agents pay off from M5 onwards. +- **The long tail:** the last 5–10% (odd real-world files, pixel-level polish) costs about as much as the first 50%. + +## Milestones + +Hours are for a single agent (low–high). "Done" is the estimated fraction of that milestone's *acceptance criteria* that are met. It is not a count of lines of code. + +| M | Milestone | Est. hours | Done | Remaining (h) | Notes | +|---|---|---|---|---|---| +| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 70% | 5–10 | Missing: GitHub workflow, `deny.toml`, remaining crate stubs, parity TOML | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 40% | 70–120 | Filters done. cos parses every xref form, repairs damaged files, and writes incremental and full saves. Open, edit and save pass on 946 of 951 corpus files. Missing: crypt (R2–R6), object-stream and xref-stream output for full saves, fuzzing, ≥ 250 tests | +| 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 | +| M3 | Viewer app (native + web) | 80–150 | 65% | 30–55 | Acrobat-style shell, find, select, panels, tiles, web build. Missing: UI control channel, 60 fps test on a 500-page document, snapshot tests of every panel | +| M4 | Engine, history, save, organize | 100–180 | 30% | 70–125 | Done: page rotate, delete, move and insert blank; info edits; undo/redo (⌘Z/⇧⌘Z, Edit menu); save and save-as (incremental, atomic write, web download); a dot on tabs with unsaved changes; save prompt on close and quit; organize-grid multi-select; CLI `edit`. Missing: command registry, autosave and recovery, combine/split, bookmark and label editing, drag-to-reorder | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 0% | 120–200 | Read-only comments panel exists | +| M6 | Forms + JavaScript | 160–320 | 0% | 160–320 | Fields are displayed, not filled | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 0% | 250–500 | Longest pole | +| M8 | Security + redaction | 100–180 | 0% | 100–180 | | +| M9 | Signatures (PAdES, validation) | 160–280 | 0% | 160–280 | | +| M10 | OCR, create, export, print | 200–350 | 0% | 200–350 | | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 0% | 200–350 | | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 0% | 200–380 | | +| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 0% | 60–120 | | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 0% | 120–250 | | +| | **Total** | **2,085–3,840** | **≈ 5%** | **≈ 1,915–3,540** | | + +**Overall progress: about 5% of the effort.** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen. + +## Critical path + +M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run in parallel across crates. The long poles are M7 (content editing), M9 (signatures) and M12 (XFA). + +## Risks most likely to push estimates up + +- Fidelity of text editing: fonts, subsets, reflow. +- XFA dynamic layout. +- Real-world signature chains and revocation checks. +- The rendering long tail: Type3 fonts, broken fonts, shadings. +- Correctness of PDF/A and PDF/UA conversion. +- CPU rendering performance on the web. + +## Log + +Newest first. One line per session: the date, what moved, and the new overall percentage. + +- **2026-09-30 (session 3):** + - New crates: `filters` (every non-image filter, 57 tests) and `cos` (object parser, xref reader, repair, incremental and full writer). + - New `organize` crate: page operations and info edits. + - Engine: editing with undo/redo and save. CLI: `edit`. + - UI: organize toolbar with multi-select, ⌘Z/⇧⌘Z/⌘S/⇧⌘S, Edit menu, editable Description properties, a dot on tabs with unsaved changes, and a save prompt on close and quit. 13 new kittest tests. + - Corpus: open, edit and save round-trip on 946 of 951 files. 182 of 182 sampled saved outputs pass `qpdf --check`. + - Overall ≈ 5%. +- **2026-09-30 (session 2):** + - Robustness sweep (963 of 983 files open, 0 crashes), text layer, find and select, tiles, web build, polish. + - Overall ≈ 3–4%. +- **2026-09-30 (session 1):** planning complete; viewer vertical slice. diff --git a/apps/printcraft-cli/Cargo.toml b/apps/printcraft-cli/Cargo.toml new file mode 100644 index 000000000..919db0e4b --- /dev/null +++ b/apps/printcraft-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "printcraft-cli" +description = "PrintCraft command-line tool: inspect, render and robustness-check PDFs" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "printcraft-cli" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +printcraft-render = { workspace = true } +serde_json = { workspace = true } +printcraft-engine = { workspace = true } diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs new file mode 100644 index 000000000..7cfca0b3a --- /dev/null +++ b/apps/printcraft-cli/src/main.rs @@ -0,0 +1,288 @@ +//! printcraft-cli — headless PrintCraft. +//! +//! ```text +//! printcraft-cli info [--password PW] document summary as JSON +//! printcraft-cli render --page N [--dpi 96] --out x.pam +//! printcraft-cli text [--page N] extracted text (pages separated by form feeds) +//! printcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] +//! [--insert-blank 1] [--title T] [--author A] [--full] +//! printcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] +//! ``` +//! +//! `check` is the robustness harness: every file is opened, inspected and fully rendered in a +//! *separate child process* with a wall-clock timeout, so hangs, panics and aborts in any +//! dependency are observed and reported instead of taking the harness down. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use printcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let result = match args.first().map(String::as_str) { + Some("info") => info(&args[1..]), + Some("render") => render(&args[1..]), + Some("text") => text(&args[1..]), + Some("edit") => edit(&args[1..]), + Some("check") => check(&args[1..]), + Some("check-one") => check_one(&args[1..]), + Some("--version") => { + println!("printcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + _ => Err("usage: printcraft-cli … (see source header for options)".into()), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("printcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn positional(args: &[String]) -> Vec<&str> { + let mut out = Vec::new(); + let mut skip = false; + for a in args { + if skip { + skip = false; + continue; + } + if a.starts_with("--") { + skip = true; + continue; + } + out.push(a.as_str()); + } + out +} + +fn read(path: &str) -> Result>, String> { + std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) +} + +fn info(args: &[String]) -> Result<(), String> { + let path = *positional(args).first().ok_or("info: missing file")?; + let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let json = serde_json::json!({ + "file": path, + "pdf_version": info.pdf_version, + "file_size": info.file_size, + "pages": info.pages.len(), + "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), + "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, + "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), + "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), + "warnings": info.warnings, + }); + println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()); + Ok(()) +} + +fn text(args: &[String]) -> Result<(), String> { + let path = *positional(args).first().ok_or("text: missing file")?; + let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() }); + let pages: Vec = match flag(args, "--page") { + Some(p) => vec![p.parse::().map_err(|_| "bad --page")?.saturating_sub(1)], + None => (0..r.page_count()).collect(), + }; + for (n, p) in pages.iter().enumerate() { + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); + if let Some(e) = out.error { + eprintln!("page {}: {e}", p + 1); + continue; + } + if n > 0 { + println!("\u{c}"); + } + println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()); + } + Ok(()) +} + +/// 1-based page list ("1,3,5") → 0-based indices. +fn page_list(s: &str) -> Result, String> { + s.split(',').map(|p| p.trim().parse::().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect() +} + +/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). +fn edit(args: &[String]) -> Result<(), String> { + use printcraft_engine::{Edit, Session}; + let path = *positional(args).first().ok_or("edit: missing file")?; + let out = flag(args, "--out").ok_or("edit: missing --out")?; + let mut session = Session::new(); + let id = session.open(path, Some(path.to_string()), read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let mut edits = Vec::new(); + let mut i = 0; + while i < args.len() { + let value = args.get(i + 1).map(String::as_str).unwrap_or(""); + match args[i].as_str() { + "--rotate" => { + let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? }); + } + "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), + "--move" => { + let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; + let to: usize = to.parse().map_err(|_| "bad target")?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) }); + } + "--insert-blank" => { + let at: usize = value.parse().map_err(|_| "bad position")?; + edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 }); + } + "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), + "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), + _ => {} + } + i += 1; + } + for e in edits { + session.apply(id, e).map_err(|e| e.to_string())?; + } + let bytes = if args.iter().any(|a| a == "--full") { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; + std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}")) +} + +fn render(args: &[String]) -> Result<(), String> { + let path = *positional(args).first().ok_or("render: missing file")?; + let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; + let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; + let out = flag(args, "--out").ok_or("render: missing --out (.pam)")?; + let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() }); + let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); + if let Some(e) = p.error { + return Err(e); + } + // PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool. + let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?; + write!(f, "P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).map_err(|e| e.to_string())?; + f.write_all(&p.rgba).map_err(|e| e.to_string())?; + eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + Ok(()) +} + +/// Child-process body for `check`: prints one JSON line. +fn check_one(args: &[String]) -> Result<(), String> { + let path = *positional(args).first().ok_or("check-one: missing file")?; + let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; + let start = Instant::now(); + let bytes = read(path)?; + let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { + Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), + Ok(info) => { + let mut r = PageRenderer::new(bytes, RenderConfig::default()); + let mut failed = Vec::new(); + for i in 0..info.pages.len().min(500) { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error { + failed.push(format!("p{} text: {e}", i + 1)); + } + if let Some(e) = p.error { + failed.push(format!("p{}: {e}", i + 1)); + } + } + let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; + (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) + } + }; + let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, + "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); + println!("{line}"); + Ok(()) +} + +fn short(s: &str) -> String { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() +} + +fn collect(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + while let Some(p) = stack.pop() { + if p.is_dir() { + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.flatten().map(|e| e.path())); + } + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + out.push(p); + } + } + out.sort(); + out +} + +fn check(args: &[String]) -> Result<(), String> { + let files = collect(&positional(args)); + if files.is_empty() { + return Err("check: no PDF files found".into()); + } + let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut results = Vec::new(); + let mut counts: std::collections::BTreeMap = Default::default(); + for (n, f) in files.iter().enumerate() { + let r = run_child(&exe, f, &dpi, timeout); + let status = r["status"].as_str().unwrap_or("?").to_string(); + *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; + if status != "ok" { + eprintln!( + "[{}/{}] {status:<28} {} {}", + n + 1, + files.len(), + f.display(), + r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + ); + } + results.push(r); + } + eprintln!("\nchecked {} files: {:?}", files.len(), counts); + if let Some(out) = flag(args, "--json") { + std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { + let file_s = file.to_string_lossy().to_string(); + let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + }; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + if let Some(mut so) = child.stdout.take() { + use std::io::Read; + let _ = so.read_to_string(&mut out); + } + return match serde_json::from_str::(out.trim()) { + Ok(v) => v, + Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), + }; + } + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + } + } +} diff --git a/apps/printcraft-web/Cargo.toml b/apps/printcraft-web/Cargo.toml new file mode 100644 index 000000000..dc7ad99cf --- /dev/null +++ b/apps/printcraft-web/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "printcraft-web" +description = "PrintCraft in the browser (wasm32; build with `trunk build --release` in this directory)" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +printcraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } +egui = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response"] } +js-sys = "0.3" diff --git a/apps/printcraft-web/index.html b/apps/printcraft-web/index.html new file mode 100644 index 000000000..5980e3676 --- /dev/null +++ b/apps/printcraft-web/index.html @@ -0,0 +1,17 @@ + + + + + + PrintCraft + + + + + + + diff --git a/apps/printcraft-web/src/main.rs b/apps/printcraft-web/src/main.rs new file mode 100644 index 000000000..151683704 --- /dev/null +++ b/apps/printcraft-web/src/main.rs @@ -0,0 +1,77 @@ +//! PrintCraft in the browser. Build: `cd apps/printcraft-web && trunk build --release` +//! (trunk generates the JS loader; no handwritten JS — plan/execution-plan.md §1). + +#[cfg(target_arch = "wasm32")] +fn main() { + use eframe::wasm_bindgen::JsCast; + use printcraft_ui_egui::PrintCraftApp; + + let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() }; + wasm_bindgen_futures::spawn_local(async move { + let Some(canvas) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("printcraft")) + .and_then(|e| e.dyn_into::().ok()) + else { + return; + }; + let started = eframe::WebRunner::new() + .start( + canvas, + options, + Box::new(|cc| { + let mut app = PrintCraftApp::new(); + if let Some(json) = cc.storage.and_then(|s| s.get_string("printcraft")) { + app.restore(&json); + } + // `?file=` opens a PDF from a URL (same-origin or CORS-enabled). + if let Some(url) = query_param("file") { + let inbox = app.inbox.clone(); + let ctx = cc.egui_ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + match fetch_bytes(&url).await { + Ok(bytes) => { + let name = + url.rsplit('/').next().unwrap_or("document.pdf").split('?').next().unwrap_or("document.pdf").to_string(); + if let Ok(mut q) = inbox.lock() { + q.push((name, bytes)); + } + ctx.request_repaint(); + } + Err(e) => eframe::web_sys::console::error_1(&format!("PrintCraft: could not fetch {url}: {e}").into()), + } + }); + } + Ok(Box::new(app)) + }), + ) + .await; + if let Err(e) = started { + eframe::web_sys::console::error_1(&e); + } + }); +} + +#[cfg(target_arch = "wasm32")] +fn query_param(key: &str) -> Option { + let search = web_sys::window()?.location().search().ok()?; + web_sys::UrlSearchParams::new_with_str(&search).ok()?.get(key) +} + +#[cfg(target_arch = "wasm32")] +async fn fetch_bytes(url: &str) -> Result, String> { + use eframe::wasm_bindgen::JsCast; + let window = web_sys::window().ok_or("no window")?; + let resp = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?; + let resp: web_sys::Response = resp.dyn_into().map_err(|_| "not a response")?; + if !resp.ok() { + return Err(format!("HTTP {}", resp.status())); + } + let buf = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?; + Ok(js_sys::Uint8Array::new(&buf).to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("printcraft-web targets wasm32: run `trunk serve` or `trunk build --release` in apps/printcraft-web"); +} diff --git a/apps/printcraft/Cargo.toml b/apps/printcraft/Cargo.toml index c959ad468..4e126586d 100644 --- a/apps/printcraft/Cargo.toml +++ b/apps/printcraft/Cargo.toml @@ -10,5 +10,5 @@ workspace = true [dependencies] printcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] } egui = { workspace = true } diff --git a/apps/printcraft/src/main.rs b/apps/printcraft/src/main.rs index a868b9aa2..819a5e079 100644 --- a/apps/printcraft/src/main.rs +++ b/apps/printcraft/src/main.rs @@ -39,8 +39,11 @@ fn main() -> eframe::Result { eframe::run_native( "PrintCraft", native, - Box::new(move |_cc| { + Box::new(move |cc| { let mut app = PrintCraftApp::new(); + if let Some(json) = cc.storage.and_then(|s| s.get_string("printcraft")) { + app.restore(&json); + } app.integrated_titlebar = integrated; for f in files { app.open_path(&f); diff --git a/assets/demo/README.md b/assets/demo/README.md new file mode 100644 index 000000000..f68a9480e --- /dev/null +++ b/assets/demo/README.md @@ -0,0 +1,78 @@ +# PrintCraft showcase PDF + +`showcase.html` is the source of a 13-page, US Letter test document for +viewing and rendering. The PDF is built on your machine and is not committed: + +```sh +cargo xtask demo-pdf # writes dist/demo/printcraft-showcase.pdf +cargo xtask demo-pdf --chrome /path/to/chrome --out /tmp/showcase.pdf +``` + +You need Google Chrome, Chromium or Edge. The tool looks for it in the usual +install locations and on `PATH`, or uses `--chrome` or the `PRINTCRAFT_CHROME` +environment variable if you set one. + +## How it is built + +1. **Raster.** The xtask renders a Mandelbrot image (Seahorse Valley) and + encodes it as a PNG. It then inlines the PNG into the HTML as a `data:` URI + and points `{{ASSETS}}` at this repository's `assets/` directory (for the + Inter and JetBrains Mono fonts). +2. **Chrome.** Headless Chrome prints the page to PDF with + `--headless=new --no-pdf-header-footer --generate-pdf-document-outline --print-to-pdf`. + Skia produces 12 tagged pages with embedded font subsets. Variable + system-ui weights come out as Type 3 fonts and emoji as Type 3 bitmap + glyphs. The pages also get shadings, blend modes, soft masks, tiling + patterns, links, named destinations and bookmarks built from the headings. +3. **Post-processing with `lopdf`** (`xtask/src/demo_pdf/`) adds what a + browser cannot emit: + - **Annotations.** The HTML contains marker links to + `https://mark.printcraft.invalid//`. Chrome turns each one into + a Link annotation whose `/Rect` follows the layout. The xtask replaces + each marker with a real annotation: Highlight, Underline, StrikeOut, + Squiggly, Caret, Text with Popup and a threaded reply and review state, + FreeText with `/DA` and `/RC`, Line with arrow, Square, Circle, Polygon, + PolyLine, Ink, a custom "APPROVED" Stamp and a FileAttachment. Every + annotation has its own `/AP` appearance stream, plus `/T`, `/M`, `/NM` + and `/Contents`. + - **An appended AcroForm page**, drawn directly with the standard 14 fonts: + - text fields, including date and amount fields with + `AFDate_*`/`AFNumber_*` format actions, and a multiline field + - checkboxes, a radio group, a combo box and a multi-select list box + - a push button whose JavaScript action calls `app.alert` + - an unsigned signature field + + Every widget has `/AP` appearance streams, so `NeedAppearances` is false. + - **Layers.** "Draft watermark" (on) puts a diagonal DRAFT on the review + page. "Print-only notes" is off on screen and on when printed, with + `/AS` usage events. + - **Page labels.** The pages are labelled Cover, i, ii, then 1 to 10. + - **Bookmarks.** Chrome's heading outline is kept, with its titles tidied, + and an entry is added for the form page. + - **Metadata and attachments.** The Info dictionary and an XMP metadata + stream carry the same values. `/ViewerPreferences /DisplayDocTitle` and + `/PageMode /UseOutlines` are set. `showcase-data.csv` is attached in + `/EmbeddedFiles` and `/AF`, and `review-notes.txt` is attached through + the file-attachment annotation. + +Chrome embeds subsets of **macOS system fonts** (Hoefler Text, Didot, Avenir +Next, Geeza Pro, Kohinoor Devanagari, Hiragino, Apple Color Emoji and others), +so the output depends on the host and we must not redistribute it. This is why +`dist/` is gitignored. On Linux or Windows the CSS falls back to other fonts +and the layout will differ. The xtask refuses to continue if Chrome's page +count is not 12, because the post-processing uses fixed page indices. + +## Editing the showcase + +- Each `
` is exactly one Letter page with + `overflow: hidden`. Keep every section's content inside its page. +- The page order is fixed in `xtask/src/demo_pdf/postprocess.rs` + (`PAGE_CONTENTS`, `PAGE_SETTING_TEXT`, `PAGE_REVIEW`). +- Marker links use `white-space: nowrap`, so each one produces a single + rectangle. +- All prose is original. The chart values in `showcase-data.csv` are + illustrative only. + +To check the result, look at the output of `pdfinfo`, `pdffonts` and +`qpdf --check`, and render the pages with +`pdftoppm -r 60 -png dist/demo/printcraft-showcase.pdf /tmp/showcase`. diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index e5b10786e..6e57fb9d0 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -64,7 +64,7 @@ body { .page:last-child { break-after: auto; } a { color: var(--blue); text-decoration: none; } -a.mk { color: inherit; } +a.mk { color: inherit; white-space: nowrap; } /* running header and folio */ .runhead { @@ -163,6 +163,11 @@ small, .small { font-size: 8pt; } .tracking div { font: 500 11pt var(--sans); text-transform: uppercase; color: var(--ink-2); padding: 3pt 0; border-bottom: 0.5pt dotted var(--rule); display: flex; justify-content: space-between; } .tracking span.v { font: 400 7pt var(--mono); color: var(--ink-3); letter-spacing: 0; text-transform: none; } +.families { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8pt; margin-top: 6pt; } +.families div { border-top: 1.5pt solid var(--ink); padding-top: 4pt; font: 400 7pt/1.3 var(--sans); color: var(--ink-3); } +.families span { display: block; font-size: 30pt; line-height: 1.25; color: var(--ink); } +.families b { display: block; font: 600 7.5pt var(--sans); color: var(--ink); } + /* ---------- body text ---------- */ .cols { column-count: 2; column-gap: 0.32in; column-rule: 0.5pt solid var(--rule); @@ -189,7 +194,7 @@ sup.fn { font: 600 6.5pt var(--sans); color: var(--accent); vertical-align: supe position: absolute; left: 0.85in; right: 0.85in; bottom: 0.85in; border-top: 0.5pt solid var(--ink-3); padding-top: 6pt; width: 2.2in; } -.footnotes-body { position: absolute; left: 0.85in; right: 0.85in; bottom: 0.82in; font: 7.5pt/1.45 var(--serif); color: var(--ink-2); columns: 2; column-gap: 0.32in; } +.footnotes-body { position: absolute; left: 0.85in; right: 0.85in; bottom: 0.82in; border-top: 0.5pt solid var(--ink-3); padding-top: 6pt; font: 7.5pt/1.45 var(--serif); color: var(--ink-2); columns: 2; column-gap: 0.32in; } .footnotes-body p { margin: 0 0 3pt; } .footnotes-body b { font: 600 6.5pt var(--sans); color: var(--accent); } @@ -209,14 +214,13 @@ sup.fn { font: 600 6.5pt var(--sans); color: var(--accent); vertical-align: supe /* ---------- expressive ---------- */ .fx-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14pt 22pt; } -.fx { border: 0.5pt solid var(--rule); border-radius: 6pt; padding: 12pt 14pt 10pt; background: #fffdf8; position: relative; overflow: hidden; height: 1.35in; } +.fx { border: 0.5pt solid var(--rule); border-radius: 6pt; padding: 12pt 14pt 10pt; background: #fffdf8; position: relative; overflow: hidden; height: 1.5in; } .fx .caption { position: absolute; bottom: 8pt; left: 14pt; right: 14pt; } -.shadow { font: 800 40pt var(--ui); color: #fffdf8; letter-spacing: -0.02em; text-shadow: 3pt 3pt 0 rgba(228,87,46,0.55), 6pt 6pt 0 rgba(42,111,219,0.35), 0 0 1pt rgba(22,33,59,0.9); } -.gradtext { display: inline-block; padding-right: 4pt; font: 800 42pt/1 var(--ui); letter-spacing: -0.03em; background: linear-gradient(120deg, #2a6fdb, #1a9e8f 40%, #e9a100 70%, #e4572e); -webkit-background-clip: text; background-clip: text; color: transparent; } -.rot { position: absolute; top: 18pt; right: 18pt; transform: rotate(-12deg); font: 700 13pt var(--sans); color: #fff; background: var(--accent); padding: 3pt 10pt; letter-spacing: 0.2em; } -.skew { display: inline-block; transform: skewX(-18deg); font: 700 28pt var(--sans); background: var(--ink); color: var(--paper); padding: 2pt 14pt; } +.shadow { font: 800 40pt "Avenir Next", var(--sans); color: #fffdf8; letter-spacing: -0.02em; text-shadow: 3pt 3pt 0 rgba(228,87,46,0.55), 6pt 6pt 0 rgba(42,111,219,0.35), 0 0 1pt rgba(22,33,59,0.9); } +.rot { position: absolute; top: 12pt; right: 14pt; transform: rotate(-12deg); font: 700 13pt var(--sans); color: #fff; background: var(--accent); padding: 3pt 10pt; letter-spacing: 0.2em; } +.skew { display: inline-block; transform: skewX(-18deg); font: 700 24pt var(--sans); background: var(--ink); color: var(--paper); padding: 2pt 14pt; } .vert-stack { font: 600 9pt var(--sans); letter-spacing: 0.3em; writing-mode: vertical-rl; transform: rotate(180deg); position: absolute; left: 12pt; top: 12pt; color: var(--teal); text-transform: uppercase; } -.outline-text { font: 800 38pt var(--ui); color: transparent; -webkit-text-stroke: 1pt var(--ink); letter-spacing: 0.02em; } +.outline-text { font: 700 34pt/1.1 "Avenir Next", var(--sans); color: transparent; -webkit-text-stroke: 1pt var(--ink); letter-spacing: 0.02em; } .tracked { font: 400 9pt var(--sans); letter-spacing: 0.6em; text-transform: uppercase; color: var(--ink-2); } /* ---------- scripts ---------- */ @@ -224,7 +228,7 @@ sup.fn { font: 600 6.5pt var(--sans); color: var(--accent); vertical-align: supe .sc { border-top: 1.5pt solid var(--ink); padding: 6pt 0 0; } .sc .nm { font: 600 6.8pt var(--sans); letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-3); display: flex; justify-content: space-between; } .sc .nm i { font-style: normal; color: var(--accent); letter-spacing: 0.05em; } -.sc .tx { font-size: 14pt; line-height: 1.35; margin-top: 3pt; } +.sc .tx { font-size: 13.5pt; white-space: nowrap; line-height: 1.35; margin-top: 3pt; } .sc .en { font: italic 8pt var(--serif); color: var(--ink-3); margin-top: 2pt; } .jp-block { writing-mode: vertical-rl; font: 15pt/1.7 "Hiragino Mincho ProN", serif; height: 2.7in; @@ -378,6 +382,15 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Measure twice, set once +120
Measure twice +300
+

The families in this specimen

+
+
AgDidotDisplay
+
AgHoefler TextBody text
+
AgAvenir NextLabels
+
AgJetBrains MonoCode
+
AgSnell RoundhandScript
+
∑xSTIX Two MathMathematics
+
Forewordii
@@ -388,16 +401,15 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Setting Text

Two justified columns with automatic hyphenation, old-style figures, a drop cap and a pull quote — the everyday machinery of a well-made page.

-

Long before there were screens, a page of type was a negotiation between the width of a column and the length of the words that had to fill it. The compositor adjusted spaces, divided a word at a sensible syllable, and occasionally rewrote a line so the paragraph would lie flat. Justified text asks for exactly that care, and a renderer must honour the spacing it was given.1

+

Long before there were screens, a page of type was a negotiation between the width of a column and the length of the words that had to fill it. The compositor adjusted spaces, divided a word at a sensible syllable, and occasionally rewrote a line so the paragraph would lie flat. Justified text asks for exactly that care, and a renderer must honour the spacing it was given.1

A PDF does not reflow. Every glyph has already been placed by the program that wrote the file, and the reader’s job is to reproduce those positions faithfully at any zoom. That sounds simple until one remembers that a single line may mix three fonts, a ligature standing in for two letters, and a hyphen that exists only because the line happened to break there in 1994 or 2026.

Old-style figures, like the ones in this paragraph’s 1,280 characters and 36 lines, rise and fall with the lowercase so that numbers do not shout. Ligatures such as fi, fl and ffi in words like officially, affluent and afflicted join letters that would otherwise collide.2

-
“The best typography is the kind you only notice when it is missing.”A working maxim of the trade
+
“Good typography is noticed only when it is missing.”A working maxim of the trade

Hyphenation is where browsers and word processors differ most. Chrome consults the system’s hyphenation dictionaries when a paragraph declares its language and asks for automatic hyphens, which is why the words in these columns occasionally divide across lines. The result is a more even texture, with fewer rivers of white running down the column.

Columns introduce their own questions: where to break, whether a heading may sit at the foot of one, and how a quotation spanning both should interrupt the flow. Here the pull quote spans the full measure and the text resumes beneath it, balanced between the two columns.

Small text matters too. Notes, captions and legal lines are often set at seven or eight points, where hinting, stem darkening and subpixel positioning decide whether a reader squints. The footnotes below are set at seven and a half points in the same face as the body.3

Finally, notice the drop cap that opens this chapter. It is an ordinary letter, floated and enlarged, with the first line set in small capitals. A renderer need do nothing special for it, which is precisely the point: most typographic richness in a PDF is just careful placement of ordinary glyphs.

-

1 Word spacing in PDF is usually baked into glyph positions rather than expressed with the word-spacing operator, so readers rarely need to stretch anything themselves.

2 In a PDF a ligature is a single glyph; text extraction relies on the font’s ToUnicode map to recover the letters it represents.

@@ -453,8 +465,8 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Effects that become transparency groups, soft masks, clipping paths and transformed text objects once they reach PDF.

Layered
text-shadow · two translucent offset copies composited under the glyphs
-
Spectrum
background-clip: text · a linear shading clipped to glyph outlines
-
Skewed
Rotated
transform · skewX(−18°) on a filled box and rotate(−12°) on a label
+
Spectrum
Gradient-filled text · an axial shading clipped to glyph outlines (text render mode 7)
+
Skewed
Rotated
transform · skewX(−18°) on a filled box and rotate(−12°) on a label
Outline
Tracked to 600
-webkit-text-stroke · glyphs stroked, not filled (render mode 1)
@@ -488,23 +500,23 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Chapter four

Scripts of the World

A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.

-
+
-
Arabic RTL · joining
لكل حرف بيت جميل
Geeza Pro
+
Arabic RTL · joining
لكل حرف بيت
Geeza Pro
Hebrew RTL
לכל אות יש בית טוב
SF Hebrew
-
Devanagari conjuncts
हर अक्षर का सुंदर घर
Kohinoor Devanagari
-
Thai no spaces
ทุกตัวอักษรมีบ้านที่ดี
Thonburi
+
Devanagari conjuncts
हर अक्षर का घर
Kohinoor Devanagari
+
Thai no spaces
ทุกอักษรมีบ้าน
Thonburi
Greek polytonic
Κάθε γράμμα ἔχει σπίτι
Hoefler Text
-
Cyrillic
У каждой буквы есть дом
Baskerville
-
Chinese simplified
每个字都有一个好家
PingFang SC
-
Korean Hangul
모든 글자에게 좋은 집을
Apple SD Gothic Neo
-
IPA phonetics
/ˈtaɪpˌsɛtɪŋ ɪz ˈkræft/
International Phonetic Alphabet
-
Armenian
Ամեն տառ ունի տուն
SF Armenian
-
Georgian
ყველა ასოს აქვს სახლი
SF Georgian
-
Tamil
ஒவ்வொரு எழுத்துக்கும் வீடு
Tamil Sangam MN
+
Cyrillic
Каждой букве — дом
Baskerville
+
Chinese simplified
每个字都有家
PingFang SC
+
Korean Hangul
모든 글자에 집을
Apple SD Gothic Neo
+
IPA phonetics
/taɪp ɪz ˈkræft/
International Phonetic Alphabet
+
Armenian
Ամեն տառին՝ տուն
SF Armenian
+
Georgian
ყველა ასოს — სახლი
SF Georgian
+
Tamil
எழுத்துக்கு வீடு
Tamil Sangam MN
-
Japanese vertical
+
Japanese
Hiragino Mincho ProN, writing-mode: vertical-rl
@@ -706,13 +718,13 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Chapter eight

Code & Images

A monospaced listing with syntax colouring, and the raster that it describes.

-
1// Escape-time rendering of the Mandelbrot set into an RGB buffer.
+
1// Escape-time rendering of Seahorse Valley into an RGB buffer.
 2fn mandelbrot(width: u32, height: u32) -> Vec<u8> {
 3    let mut rgb = Vec::with_capacity((width * height * 3) as usize);
 4    for py in 0..height {
 5        for px in 0..width {
-6            let c = (-2.2 + 3.0 * px as f64 / width as f64,
-7                     -1.5 + 3.0 * py as f64 / height as f64);
+6            let c = (CX + SPAN * (px as f64 / width as f64 - 0.5),
+7                     CY - SPAN * (py as f64 / height as f64 - 0.5));
 8            let (mut z, mut n) = ((0.0, 0.0), 0);
 9            while n < MAX_ITER && z.0 * z.0 + z.1 * z.1 <= 4.0 {
 10               z = (z.0 * z.0 - z.1 * z.1 + c.0, 2.0 * z.0 * z.1 + c.1);
diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml
new file mode 100644
index 000000000..a5623b211
--- /dev/null
+++ b/crates/cos/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "printcraft-cos"
+description = "PDF object layer: lazy tolerant parsing, copy-on-write edits, incremental and full saving"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+printcraft-filters = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+proptest = { workspace = true }
+hayro-syntax = { workspace = true }
diff --git a/crates/cos/examples/cosinfo.rs b/crates/cos/examples/cosinfo.rs
new file mode 100644
index 000000000..a35242b57
--- /dev/null
+++ b/crates/cos/examples/cosinfo.rs
@@ -0,0 +1,17 @@
+//! Print how a file was opened: revisions, repair notes, trailer. `cargo run -p printcraft-cos --example cosinfo f.pdf`
+fn main() {
+    let path = std::env::args().nth(1).expect("usage: cosinfo ");
+    let bytes = std::sync::Arc::new(std::fs::read(&path).expect("readable"));
+    match printcraft_cos::Document::open(bytes) {
+        Ok(doc) => {
+            println!("version {}  revisions {:?}", doc.version(), doc.revisions());
+            for r in doc.repair_log() {
+                println!("repair: {r}");
+            }
+            let mut t = Vec::new();
+            printcraft_cos::serialize(&printcraft_cos::Object::Dict(doc.trailer().clone()), &mut t);
+            println!("trailer {}", String::from_utf8_lossy(&t));
+        }
+        Err(e) => println!("error: {e}"),
+    }
+}
diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs
new file mode 100644
index 000000000..39271981e
--- /dev/null
+++ b/crates/cos/src/document.rs
@@ -0,0 +1,708 @@
+//! Document: cross-reference data, lazy object loading, and a copy-on-write overlay of edits.
+//!
+//! `Document` is cheap to clone (the original bytes, xref index and parse caches are shared), so
+//! a clone is a snapshot: undo/redo and background jobs hold clones while the UI edits another.
+//! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites
+//! the file (full save).
+
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::sync::{Arc, Mutex};
+
+use crate::CosError;
+use crate::object::{Dict, ObjRef, Object};
+use crate::parser::{Lexer, is_whitespace, parse_indirect};
+
+thread_local! {
+    static LOAD_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Nesting counter for object loads on this thread (see `Document::try_get`).
+struct LoadGuard;
+
+impl LoadGuard {
+    const MAX: u32 = 32;
+
+    fn enter() -> Option {
+        LOAD_DEPTH.with(|d| {
+            if d.get() >= Self::MAX {
+                return None;
+            }
+            d.set(d.get() + 1);
+            Some(LoadGuard)
+        })
+    }
+}
+
+impl Drop for LoadGuard {
+    fn drop(&mut self) {
+        LOAD_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum XrefEntry {
+    Free { next_generation: u16 },
+    InFile { offset: u64, generation: u16 },
+    InStream { stream: u32, index: u32 },
+}
+
+/// One cross-reference section of the file (one per save; the first is the original).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Revision {
+    /// Offset of the xref section (`startxref` value).
+    pub xref_offset: u64,
+    /// `true` if the section is a cross-reference stream.
+    pub is_stream: bool,
+}
+
+#[derive(Clone, Debug)]
+struct ObjStm {
+    /// (object number, byte offset within `data`).
+    index: Vec<(u32, usize)>,
+    data: Arc>,
+}
+
+/// Where an overlay slot came from.
+#[derive(Clone, Debug, PartialEq)]
+enum Slot {
+    Set(u16, Arc),
+    Freed(u16),
+}
+
+#[derive(Clone)]
+pub struct Document {
+    data: Arc>,
+    entries: Arc>,
+    trailer: Dict,
+    revisions: Arc>,
+    repair_log: Arc>,
+    cache: Arc>>>,
+    objstms: Arc>>>,
+    overlay: BTreeMap,
+    next_num: u32,
+    /// Position of the `%PDF-` header (some files have junk before it).
+    header_offset: usize,
+    version: String,
+}
+
+impl std::fmt::Debug for Document {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Document").field("bytes", &self.data.len()).field("objects", &self.entries.len()).field("edits", &self.overlay.len()).finish()
+    }
+}
+
+impl Document {
+    /// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`.
+    pub fn open(data: Arc>) -> Result {
+        // Viewers accept files whose header is missing or damaged as long as the body looks
+        // like PDF; so do we (a note goes to the repair log).
+        let header = find(&data, b"%PDF-", 0, 1024);
+        let headerless = header.is_none();
+        if headerless && find(&data, b" obj", 0, 4096).is_none() {
+            return Err(CosError::NotPdf);
+        }
+        let header_offset = header.unwrap_or(0);
+        let version = match header {
+            Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(),
+            None => "1.4".to_string(),
+        };
+        let mut doc = Document {
+            data,
+            entries: Arc::new(BTreeMap::new()),
+            trailer: Dict::new(),
+            revisions: Arc::new(Vec::new()),
+            repair_log: Arc::new(Vec::new()),
+            cache: Arc::default(),
+            objstms: Arc::default(),
+            overlay: BTreeMap::new(),
+            next_num: 1,
+            header_offset,
+            version,
+        };
+        let mut log = Vec::new();
+        if headerless {
+            log.push("the %PDF- header is missing; reading the file as PDF 1.4".into());
+        }
+        let parsed = doc.read_xref_chain(&mut log);
+        let ok = match parsed {
+            Ok((entries, trailer, revisions)) => {
+                doc.entries = Arc::new(entries);
+                doc.trailer = trailer;
+                doc.revisions = Arc::new(revisions);
+                doc.root_is_catalog()
+            }
+            Err(e) => {
+                log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
+                false
+            }
+        };
+        if !ok {
+            if !doc.entries.is_empty() {
+                log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
+            }
+            doc.reconstruct(&mut log)?;
+        }
+        doc.repair_log = Arc::new(log);
+        if doc.trailer.contains(b"Encrypt") {
+            return Err(CosError::Encrypted);
+        }
+        let max = doc.entries.keys().next_back().copied().unwrap_or(0);
+        let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32;
+        doc.next_num = max.max(size.saturating_sub(1)) + 1;
+        Ok(doc)
+    }
+
+    pub fn bytes(&self) -> &Arc> {
+        &self.data
+    }
+
+    pub fn version(&self) -> &str {
+        &self.version
+    }
+
+    pub fn trailer(&self) -> &Dict {
+        &self.trailer
+    }
+
+    pub fn trailer_mut(&mut self) -> &mut Dict {
+        &mut self.trailer
+    }
+
+    pub fn revisions(&self) -> &[Revision] {
+        &self.revisions
+    }
+
+    pub fn repair_log(&self) -> &[String] {
+        &self.repair_log
+    }
+
+    /// `true` when there are unsaved edits.
+    pub fn is_modified(&self) -> bool {
+        !self.overlay.is_empty()
+    }
+
+    /// Object numbers changed since the document was opened (or last saved).
+    pub fn modified_objects(&self) -> Vec {
+        self.overlay.keys().copied().collect()
+    }
+
+    pub fn root(&self) -> Option {
+        self.trailer.reference(b"Root")
+    }
+
+    // ── object access ───────────────────────────────────────────────────────────────────────
+
+    /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10).
+    pub fn get(&self, r: ObjRef) -> Arc {
+        self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null))
+    }
+
+    /// Follow a reference if `o` is one; otherwise return `o` itself.
+    pub fn resolve(&self, o: &Object) -> Arc {
+        match o {
+            Object::Ref(r) => self.get(*r),
+            other => Arc::new(other.clone()),
+        }
+    }
+
+    /// Resolve and return a dictionary (a stream's dictionary counts).
+    pub fn dict(&self, o: &Object) -> Option {
+        self.resolve(o).as_dict().cloned()
+    }
+
+    pub fn try_get(&self, num: u32) -> Result, CosError> {
+        match self.overlay.get(&num) {
+            Some(Slot::Set(_, o)) => return Ok(o.clone()),
+            Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
+            None => {}
+        }
+        if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
+            return Ok(o.clone());
+        }
+        // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
+        // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
+        let _guard =
+            LoadGuard::enter().ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
+        let obj = self.load(num, 0)?;
+        let obj = Arc::new(obj);
+        self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
+        Ok(obj)
+    }
+
+    fn load(&self, num: u32, depth: u32) -> Result {
+        if depth > 16 {
+            return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() });
+        }
+        match self.entries.get(&num) {
+            None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
+            Some(XrefEntry::InFile { offset, .. }) => {
+                let resolve = |r: ObjRef| self.try_get(r.num).ok().and_then(|o| o.as_int());
+                let off = *offset as usize;
+                match parse_indirect(&self.data, off, &resolve) {
+                    Ok((id, o)) if id.num == num => Ok(o),
+                    // Offsets relative to a shifted header, or simply wrong: try both fixes.
+                    _ => match parse_indirect(&self.data, off + self.header_offset, &resolve) {
+                        Ok((id, o)) if id.num == num => Ok(o),
+                        _ => self.scan_for(num).ok_or(CosError::MissingObject(num)),
+                    },
+                }
+            }
+            Some(XrefEntry::InStream { stream, index }) => {
+                let stm = self.objstm(*stream)?;
+                let (n, off) = stm
+                    .index
+                    .get(*index as usize)
+                    .copied()
+                    .or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied())
+                    .ok_or(CosError::MissingObject(num))?;
+                let off =
+                    if n == num { off } else { stm.index.iter().find(|(n, _)| *n == num).map(|(_, o)| *o).ok_or(CosError::MissingObject(num))? };
+                Lexer::new(&stm.data, off).object()
+            }
+        }
+    }
+
+    fn objstm(&self, num: u32) -> Result, CosError> {
+        if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
+            return Ok(s.clone());
+        }
+        let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
+        let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
+        let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
+        let data = s.decoded()?;
+        let mut lx = Lexer::new(&data, 0);
+        let mut index = Vec::with_capacity(n);
+        for _ in 0..n {
+            let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
+            index.push((onum.max(0) as u32, first + off.max(0) as usize));
+        }
+        let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
+        self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
+        Ok(stm)
+    }
+
+    /// Last-resort lookup: scan the file for `num G obj`.
+    fn scan_for(&self, num: u32) -> Option {
+        let needle = format!("{num} ");
+        let data = &self.data;
+        let mut found = None;
+        let mut i = 0;
+        while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
+            i = p + 1;
+            if p > 0 && !is_whitespace(data[p - 1]) {
+                continue;
+            }
+            if let Ok((id, o)) = parse_indirect(data, p, &|_| None)
+                && id.num == num
+            {
+                found = Some(o); // keep the last (newest) definition
+            }
+        }
+        found
+    }
+
+    // ── editing ─────────────────────────────────────────────────────────────────────────────
+
+    /// Replace an object (keeping its generation).
+    pub fn set(&mut self, r: ObjRef, obj: impl Into) {
+        self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj.into())));
+        if r.num >= self.next_num {
+            self.next_num = r.num + 1;
+        }
+    }
+
+    /// Add a new indirect object and return its reference.
+    pub fn add(&mut self, obj: impl Into) -> ObjRef {
+        let r = ObjRef::new(self.next_num, 0);
+        self.next_num += 1;
+        self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj.into())));
+        r
+    }
+
+    /// Delete an object (its number becomes free with the next generation).
+    pub fn free(&mut self, r: ObjRef) {
+        self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
+    }
+
+    /// Edit a dictionary (or stream dictionary) object in place.
+    pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> {
+        let mut obj = (*self.get(r)).clone();
+        let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?;
+        f(d);
+        self.set(r, obj);
+        Ok(())
+    }
+
+    /// Current generation of an object number.
+    pub fn generation(&self, num: u32) -> u16 {
+        match self.overlay.get(&num) {
+            Some(Slot::Set(g, _)) => *g,
+            Some(Slot::Freed(g)) => *g,
+            None => match self.entries.get(&num) {
+                Some(XrefEntry::InFile { generation, .. }) => *generation,
+                Some(XrefEntry::Free { next_generation }) => *next_generation,
+                _ => 0,
+            },
+        }
+    }
+
+    pub(crate) fn overlay_entries(&self) -> impl Iterator>)> + '_ {
+        self.overlay.iter().map(|(n, s)| match s {
+            Slot::Set(g, o) => (*n, *g, Some(o.clone())),
+            Slot::Freed(g) => (*n, *g, None),
+        })
+    }
+
+    pub(crate) fn next_num(&self) -> u32 {
+        self.next_num
+    }
+
+    /// All object numbers known (file and overlay), excluding freed ones.
+    pub fn object_numbers(&self) -> Vec {
+        let mut set: HashSet = self.entries.iter().filter(|(_, e)| !matches!(e, XrefEntry::Free { .. })).map(|(n, _)| *n).collect();
+        for (n, s) in &self.overlay {
+            match s {
+                Slot::Set(..) => set.insert(*n),
+                Slot::Freed(_) => set.remove(n),
+            };
+        }
+        let mut v: Vec = set.into_iter().collect();
+        v.sort_unstable();
+        v
+    }
+
+    /// Rebase this document on bytes written by the writer (after a save): edits become part of
+    /// the file and the overlay is cleared, so the next incremental save appends only new edits.
+    pub fn reopen_after_save(&self, bytes: Arc>) -> Result {
+        Document::open(bytes)
+    }
+
+    // ── cross-reference parsing ─────────────────────────────────────────────────────────────
+
+    fn root_is_catalog(&self) -> bool {
+        let Some(r) = self.root() else { return false };
+        matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
+    }
+
+    #[allow(clippy::type_complexity)]
+    fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec), CosError> {
+        let data = &self.data;
+        let tail_start = data.len().saturating_sub(4096);
+        let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
+        let mut lx = Lexer::new(data, sx + 9);
+        let first = match lx.object() {
+            Ok(Object::Int(n)) if n >= 0 => n as u64,
+            _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
+        };
+        let mut entries = BTreeMap::new();
+        let mut trailer: Option = None;
+        let mut revisions = Vec::new();
+        let mut visited = HashSet::new();
+        let mut next = Some(first);
+        while let Some(off) = next.take() {
+            if !visited.insert(off) || visited.len() > 10_000 {
+                log.push(format!("cross-reference chain loops at offset {off}; stopped"));
+                break;
+            }
+            let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut entries) {
+                Ok(v) => v,
+                Err(e) if self.header_offset > 0 => {
+                    log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
+                    self.read_section(off as usize + self.header_offset, &mut entries)?
+                }
+                Err(e) => {
+                    if revisions.is_empty() {
+                        return Err(e);
+                    }
+                    log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
+                    break;
+                }
+            };
+            revisions.push(Revision { xref_offset: off, is_stream });
+            // Hybrid files: the /XRefStm entries supplement the table (§7.5.8.4).
+            if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0)
+                && let Err(e) = self.read_section(x as usize, &mut entries)
+            {
+                log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
+            }
+            next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
+            if trailer.is_none() {
+                trailer = Some(sect_trailer);
+            }
+        }
+        revisions.reverse();
+        let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
+        for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
+            trailer.remove(k);
+        }
+        Ok((entries, trailer, revisions))
+    }
+
+    /// Read one section at `off`, adding entries not already known (newer sections win).
+    fn read_section(&self, off: usize, entries: &mut BTreeMap) -> Result<(Dict, bool), CosError> {
+        let data = &self.data;
+        let mut lx = Lexer::new(data, off);
+        if lx.eat_keyword(b"xref") {
+            loop {
+                lx.skip_ws();
+                if lx.eat_keyword(b"trailer") {
+                    let t = lx.object()?;
+                    let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
+                    return Ok((d, false));
+                }
+                let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
+                    return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
+                };
+                if start < 0 || !(0..=10_000_000).contains(&count) {
+                    return Err(CosError::Syntax { offset: lx.pos, detail: "implausible xref subsection".into() });
+                }
+                for i in 0..count {
+                    lx.skip_ws();
+                    let o = lx.token();
+                    lx.skip_ws();
+                    let g = lx.token();
+                    lx.skip_ws();
+                    let kind = lx.token();
+                    let num = (start + i) as u32;
+                    let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok());
+                    let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0).min(u16::MAX as u32) as u16;
+                    let entry = match (kind, offset) {
+                        (b"n", Some(offset)) => XrefEntry::InFile { offset, generation },
+                        (b"f", _) => XrefEntry::Free { next_generation: generation },
+                        _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
+                    };
+                    // Offset 0 "in use" entries are a known producer bug: ignore them.
+                    if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
+                        continue;
+                    }
+                    entries.entry(num).or_insert(entry);
+                }
+            }
+        }
+        // Cross-reference stream.
+        let (_, obj) = parse_indirect(data, off, &|_| None)?;
+        let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
+        if s.dict.name(b"Type") != Some(b"XRef") {
+            return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
+        }
+        let w: Vec = s
+            .dict
+            .get(b"W")
+            .and_then(Object::as_array)
+            .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
+            .unwrap_or_default();
+        if w.len() < 3 {
+            return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
+        }
+        let size = s.dict.int(b"Size").unwrap_or(0).max(0);
+        let index: Vec = match s.dict.get(b"Index").and_then(Object::as_array) {
+            Some(a) => a.iter().filter_map(Object::as_int).collect(),
+            None => vec![0, size],
+        };
+        let raw = s.decoded()?;
+        let row = w[0] + w[1] + w[2];
+        if row == 0 {
+            return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
+        }
+        let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 {
+            if len == 0 {
+                return default;
+            }
+            r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64)
+        };
+        let mut rows = raw.chunks_exact(row);
+        for pair in index.chunks(2) {
+            let [start, count] = pair else { break };
+            for i in 0..(*count).max(0) {
+                let Some(r) = rows.next() else { break };
+                let t = field(r, 0, w[0], 1);
+                let a = field(r, w[0], w[1], 0);
+                let b = field(r, w[0] + w[1], w[2], 0);
+                let num = (*start + i).max(0) as u32;
+                let entry = match t {
+                    0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
+                    1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
+                    2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
+                    _ => continue, // reserved types are treated as null references (§7.5.8.3)
+                };
+                if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
+                    continue;
+                }
+                entries.entry(num).or_insert(entry);
+            }
+        }
+        Ok((s.dict.clone(), true))
+    }
+
+    /// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
+    fn reconstruct(&mut self, log: &mut Vec) -> Result<(), CosError> {
+        let data = self.data.clone();
+        let mut entries: BTreeMap = BTreeMap::new();
+        let mut i = 0;
+        let mut objstms = Vec::new();
+        while i < data.len() {
+            // Object headers start at a line start (or after whitespace).
+            let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
+            if at_start && data[i].is_ascii_digit() {
+                let mut lx = Lexer::new(&data, i);
+                let n = lx.token();
+                lx.skip_ws();
+                let g = lx.token();
+                if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
+                    let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::().ok());
+                    let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0);
+                    if let Some(num) = num {
+                        entries.insert(num, XrefEntry::InFile { offset: i as u64, generation });
+                        objstms.push(num);
+                    }
+                    i = lx.pos;
+                    continue;
+                }
+            }
+            i += 1;
+        }
+        if entries.is_empty() {
+            return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
+        }
+        self.entries = Arc::new(entries.clone());
+        self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
+        // Objects inside object streams.
+        for num in objstms {
+            if let Ok(o) = self.try_get(num)
+                && let Object::Stream(s) = &*o
+                && s.dict.name(b"Type") == Some(b"ObjStm")
+                && let Ok(stm) = self.objstm(num)
+            {
+                for (idx, (inner, _)) in stm.index.iter().enumerate() {
+                    entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
+                }
+            }
+        }
+        self.entries = Arc::new(entries);
+        self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
+        // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
+        let mut trailer = None;
+        let mut from = 0;
+        while let Some(p) = find(&data, b"trailer", from, data.len()) {
+            from = p + 7;
+            let mut lx = Lexer::new(&data, from);
+            if let Ok(Object::Dict(d)) = lx.object()
+                && d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
+            {
+                trailer = Some(d);
+            }
+        }
+        let trailer = match trailer {
+            Some(t) => t,
+            None => {
+                let catalog = self
+                    .entries
+                    .keys()
+                    .copied()
+                    .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog")));
+                let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) };
+                let mut t = Dict::new();
+                t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c))));
+                t
+            }
+        };
+        let mut trailer = trailer;
+        for k in [&b"Prev"[..], b"XRefStm"] {
+            trailer.remove(k);
+        }
+        self.trailer = trailer;
+        self.revisions = Arc::new(Vec::new());
+        log.push(format!("rebuilt cross-reference index from {} object headers", self.entries.len()));
+        Ok(())
+    }
+}
+
+pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option {
+    let to = to.min(hay.len());
+    if from >= to || needle.is_empty() || to - from < needle.len() {
+        return None;
+    }
+    hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
+}
+
+fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option {
+    if hay.len() < needle.len() {
+        return None;
+    }
+    (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
+    pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec {
+        let mut out = b"%PDF-1.7\n".to_vec();
+        let mut offsets = Vec::new();
+        for (i, b) in bodies.iter().enumerate() {
+            offsets.push(out.len());
+            out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
+        }
+        let xref = out.len();
+        out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.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 {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
+        out
+    }
+
+    #[test]
+    fn self_referential_length_does_not_overflow() {
+        let bytes = build(
+            &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"],
+            "/Root 1 0 R",
+        );
+        let doc = Document::open(Arc::new(bytes)).unwrap();
+        // Either recovered via the endstream search or reported — but never a stack overflow.
+        let o = doc.get(ObjRef::new(3, 0));
+        if let Object::Stream(s) = o.as_ref() {
+            assert_eq!(s.raw.as_slice(), b"abc");
+        }
+    }
+
+    #[test]
+    fn mutually_referential_lengths_do_not_overflow() {
+        let bytes = build(
+            &[
+                "<< /Type /Catalog /Pages 2 0 R >>",
+                "<< /Type /Pages /Kids [] /Count 0 >>",
+                "<< /Length 4 0 R >>\nstream\nabc\nendstream",
+                "<< /Length 3 0 R >>\nstream\nxyz\nendstream",
+            ],
+            "/Root 1 0 R",
+        );
+        let doc = Document::open(Arc::new(bytes)).unwrap();
+        let _ = doc.get(ObjRef::new(3, 0));
+        let _ = doc.get(ObjRef::new(4, 0));
+    }
+
+    #[test]
+    fn reads_classic_revision_and_edits_are_copy_on_write() {
+        let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+        let doc = Document::open(Arc::new(bytes)).unwrap();
+        assert_eq!(doc.revisions().len(), 1);
+        assert!(doc.repair_log().is_empty());
+        let mut edited = doc.clone();
+        edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
+        assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot");
+        assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
+    }
+
+    #[test]
+    fn damaged_xref_is_reconstructed() {
+        let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+        let n = bytes.len();
+        bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere
+        let doc = Document::open(Arc::new(bytes)).unwrap();
+        assert!(doc.revisions().is_empty());
+        assert!(!doc.repair_log().is_empty());
+        assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
+    }
+}
diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs
new file mode 100644
index 000000000..63152d887
--- /dev/null
+++ b/crates/cos/src/lib.rs
@@ -0,0 +1,38 @@
+//! printcraft-cos — the PDF object layer (L1, standalone with `printcraft-filters`).
+//!
+//! Parses the COS object graph lazily and tolerantly, keeps edits in a copy-on-write overlay
+//! (cheap snapshots for undo), and writes documents back either incrementally (original bytes
+//! untouched) or as a full, garbage-collected rewrite. The PDF object graph *is* PrintCraft's
+//! document model (plan/architecture.md §5, ADR-0010).
+//!
+//! Status (M1 in progress): parsing of all xref forms, object streams, repair by scanning,
+//! incremental and full writing. Not yet: encryption (M1.6), object-stream / xref-stream output
+//! for full saves, linearization (M11).
+
+mod document;
+mod object;
+mod parser;
+mod writer;
+
+pub use document::{Document, Revision, XrefEntry};
+pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
+pub use parser::{Lexer, parse_indirect};
+pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental};
+
+#[derive(Debug, thiserror::Error, Clone, PartialEq)]
+pub enum CosError {
+    #[error("not a PDF file (no %PDF- header)")]
+    NotPdf,
+    #[error("syntax error at byte {offset}: {detail}")]
+    Syntax { offset: usize, detail: String },
+    #[error("object {0} is missing")]
+    MissingObject(u32),
+    #[error("object {0} is not a dictionary")]
+    NotADictionary(ObjRef),
+    #[error("stream data could not be decoded: {0}")]
+    Filter(String),
+    #[error("the document is encrypted; editing encrypted documents arrives with M1.6")]
+    Encrypted,
+    #[error("internal lock poisoned")]
+    Poisoned,
+}
diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs
new file mode 100644
index 000000000..01ad7824e
--- /dev/null
+++ b/crates/cos/src/object.rs
@@ -0,0 +1,386 @@
+//! The PDF object model (ISO 32000-2 §7.3).
+//!
+//! Dictionaries keep their key order so rewritten objects stay recognisable in diffs and
+//! byte-stable when re-serialized. Streams keep their *encoded* bytes; decoding is on demand.
+
+use std::sync::Arc;
+
+use printcraft_filters::{Filter, Params};
+
+use crate::CosError;
+
+/// An indirect reference: object number and generation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct ObjRef {
+    pub num: u32,
+    pub generation: u16,
+}
+
+impl ObjRef {
+    pub const fn new(num: u32, generation: u16) -> Self {
+        Self { num, generation }
+    }
+}
+
+impl std::fmt::Display for ObjRef {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{} {} R", self.num, self.generation)
+    }
+}
+
+/// A string object. `hex` records how it was written, so it is re-emitted the same way.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PdfString {
+    pub bytes: Vec,
+    pub hex: bool,
+}
+
+impl PdfString {
+    pub fn literal(bytes: impl Into>) -> Self {
+        Self { bytes: bytes.into(), hex: false }
+    }
+
+    /// A text string (ISO 32000-2 §7.9.2.2): PDFDocEncoding when possible, else UTF-16BE with BOM.
+    pub fn text(s: &str) -> Self {
+        if s.chars().all(|c| (' '..='~').contains(&c) || c == '\n' || c == '\r' || c == '\t') {
+            return Self::literal(s.as_bytes().to_vec());
+        }
+        let mut bytes = vec![0xFE, 0xFF];
+        for u in s.encode_utf16() {
+            bytes.extend_from_slice(&u.to_be_bytes());
+        }
+        Self { bytes, hex: false }
+    }
+
+    /// Decode a text string: UTF-16BE (BOM), UTF-8 (BOM, PDF 2.0) or PDFDocEncoding.
+    pub fn to_text(&self) -> String {
+        let b = &self.bytes;
+        if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
+            let units: Vec = b[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
+            return String::from_utf16_lossy(&units);
+        }
+        if b.len() >= 3 && b[..3] == [0xEF, 0xBB, 0xBF] {
+            return String::from_utf8_lossy(&b[3..]).into_owned();
+        }
+        b.iter().map(|&c| pdfdoc_char(c)).collect()
+    }
+}
+
+/// PDFDocEncoding → Unicode (ISO 32000-2 Annex D). Bytes 0x18–0x1F and 0x80–0xA0 differ from Latin-1.
+fn pdfdoc_char(c: u8) -> char {
+    const HIGH: [char; 33] = [
+        '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł',
+        'œ', 'š', 'ž', '\u{FFFD}', '€',
+    ];
+    const LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜'];
+    match c {
+        0x18..=0x1F => LOW[(c - 0x18) as usize],
+        0x80..=0xA0 => HIGH[(c - 0x80) as usize],
+        _ => c as char,
+    }
+}
+
+/// A name object (without the leading `/`, with `#xx` escapes already decoded).
+pub type Name = Vec;
+
+/// An ordered dictionary.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Dict {
+    entries: Vec<(Name, Object)>,
+}
+
+impl Dict {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn get(&self, key: &[u8]) -> Option<&Object> {
+        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
+    }
+
+    pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> {
+        self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
+    }
+
+    /// Insert or replace, keeping the position of an existing key.
+    pub fn set(&mut self, key: impl Into, value: impl Into) {
+        let key = key.into();
+        let value = value.into();
+        match self.entries.iter_mut().find(|(k, _)| *k == key) {
+            Some((_, v)) => *v = value,
+            None => self.entries.push((key, value)),
+        }
+    }
+
+    pub fn remove(&mut self, key: &[u8]) -> Option {
+        let i = self.entries.iter().position(|(k, _)| k == key)?;
+        Some(self.entries.remove(i).1)
+    }
+
+    pub fn contains(&self, key: &[u8]) -> bool {
+        self.get(key).is_some()
+    }
+
+    pub fn iter(&self) -> impl Iterator {
+        self.entries.iter().map(|(k, v)| (k, v))
+    }
+
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+
+    pub fn name(&self, key: &[u8]) -> Option<&[u8]> {
+        self.get(key).and_then(Object::as_name)
+    }
+
+    pub fn int(&self, key: &[u8]) -> Option {
+        self.get(key).and_then(Object::as_int)
+    }
+
+    pub fn reference(&self, key: &[u8]) -> Option {
+        self.get(key).and_then(Object::as_ref)
+    }
+}
+
+impl FromIterator<(Name, Object)> for Dict {
+    fn from_iter>(iter: T) -> Self {
+        let mut d = Dict::new();
+        for (k, v) in iter {
+            d.set(k, v);
+        }
+        d
+    }
+}
+
+/// A stream: dictionary plus its data *as stored* (still filtered).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Stream {
+    pub dict: Dict,
+    pub raw: Arc>,
+}
+
+/// Upper bound for a single decoded stream (decompression-bomb defence).
+pub const MAX_DECODED: usize = 1 << 30;
+
+impl Stream {
+    /// A new stream from already-encoded bytes (`/Length` is set at write time).
+    pub fn from_raw(dict: Dict, raw: Vec) -> Self {
+        Self { dict, raw: Arc::new(raw) }
+    }
+
+    /// A new stream holding `data` compressed with Flate.
+    pub fn flate(mut dict: Dict, data: &[u8]) -> Self {
+        dict.set(b"Filter".to_vec(), Object::Name(b"FlateDecode".to_vec()));
+        dict.remove(b"DecodeParms");
+        Self { dict, raw: Arc::new(printcraft_filters::encode_flate(data)) }
+    }
+
+    /// The filter chain declared in the dictionary.
+    pub fn filters(&self) -> Vec<(Filter, Params)> {
+        let names: Vec<&[u8]> = match self.dict.get(b"Filter") {
+            Some(Object::Name(n)) => vec![n],
+            Some(Object::Array(a)) => a.iter().filter_map(Object::as_name).collect(),
+            _ => Vec::new(),
+        };
+        let parms: Vec> = match self.dict.get(b"DecodeParms") {
+            Some(Object::Dict(d)) => vec![Some(d)],
+            Some(Object::Array(a)) => a.iter().map(Object::as_dict).collect(),
+            _ => Vec::new(),
+        };
+        names
+            .into_iter()
+            .enumerate()
+            .map(|(i, n)| {
+                let p = parms.get(i).copied().flatten();
+                let get = |k: &[u8], d: i64| p.and_then(|p| p.int(k)).unwrap_or(d);
+                let params = Params {
+                    predictor: get(b"Predictor", 1),
+                    colors: get(b"Colors", 1),
+                    bits_per_component: get(b"BitsPerComponent", 8),
+                    columns: get(b"Columns", 1),
+                    early_change: get(b"EarlyChange", 1),
+                };
+                (Filter::from_name(n), params)
+            })
+            .collect()
+    }
+
+    /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
+    /// decoded before the damage is returned). Streams with image codecs fail with
+    /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
+    pub fn decoded(&self) -> Result, CosError> {
+        let chain = self.filters();
+        if chain.is_empty() {
+            return Ok(self.raw.as_ref().clone());
+        }
+        printcraft_filters::decode_tolerant(&chain, &self.raw, MAX_DECODED).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
+    }
+
+    /// Decoded data; any corruption is an error.
+    pub fn decoded_strict(&self) -> Result, CosError> {
+        let chain = self.filters();
+        if chain.is_empty() {
+            return Ok(self.raw.as_ref().clone());
+        }
+        printcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Object {
+    Null,
+    Bool(bool),
+    Int(i64),
+    Real(f64),
+    String(PdfString),
+    Name(Name),
+    Array(Vec),
+    Dict(Dict),
+    Stream(Stream),
+    Ref(ObjRef),
+}
+
+impl Object {
+    pub fn name(n: &str) -> Self {
+        Object::Name(n.as_bytes().to_vec())
+    }
+
+    pub fn as_int(&self) -> Option {
+        match self {
+            Object::Int(i) => Some(*i),
+            Object::Real(r) if r.fract() == 0.0 && r.is_finite() => Some(*r as i64),
+            _ => None,
+        }
+    }
+
+    pub fn as_f64(&self) -> Option {
+        match self {
+            Object::Int(i) => Some(*i as f64),
+            Object::Real(r) => Some(*r),
+            _ => None,
+        }
+    }
+
+    pub fn as_name(&self) -> Option<&[u8]> {
+        match self {
+            Object::Name(n) => Some(n),
+            _ => None,
+        }
+    }
+
+    pub fn as_ref(&self) -> Option {
+        match self {
+            Object::Ref(r) => Some(*r),
+            _ => None,
+        }
+    }
+
+    pub fn as_dict(&self) -> Option<&Dict> {
+        match self {
+            Object::Dict(d) => Some(d),
+            Object::Stream(s) => Some(&s.dict),
+            _ => None,
+        }
+    }
+
+    pub fn as_dict_mut(&mut self) -> Option<&mut Dict> {
+        match self {
+            Object::Dict(d) => Some(d),
+            Object::Stream(s) => Some(&mut s.dict),
+            _ => None,
+        }
+    }
+
+    pub fn as_array(&self) -> Option<&Vec> {
+        match self {
+            Object::Array(a) => Some(a),
+            _ => None,
+        }
+    }
+
+    pub fn as_string(&self) -> Option<&PdfString> {
+        match self {
+            Object::String(s) => Some(s),
+            _ => None,
+        }
+    }
+}
+
+impl From for Object {
+    fn from(v: i64) -> Self {
+        Object::Int(v)
+    }
+}
+impl From for Object {
+    fn from(v: i32) -> Self {
+        Object::Int(v as i64)
+    }
+}
+impl From for Object {
+    fn from(v: f64) -> Self {
+        Object::Real(v)
+    }
+}
+impl From for Object {
+    fn from(v: bool) -> Self {
+        Object::Bool(v)
+    }
+}
+impl From for Object {
+    fn from(v: ObjRef) -> Self {
+        Object::Ref(v)
+    }
+}
+impl From for Object {
+    fn from(v: Dict) -> Self {
+        Object::Dict(v)
+    }
+}
+impl From> for Object {
+    fn from(v: Vec) -> Self {
+        Object::Array(v)
+    }
+}
+impl From for Object {
+    fn from(v: PdfString) -> Self {
+        Object::String(v)
+    }
+}
+impl From for Object {
+    fn from(v: Stream) -> Self {
+        Object::Stream(v)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn text_strings_round_trip() {
+        for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] {
+            assert_eq!(PdfString::text(s).to_text(), s);
+        }
+        assert!(!PdfString::text("abc").bytes.starts_with(&[0xFE, 0xFF]));
+    }
+
+    #[test]
+    fn pdfdoc_encoding_high_range() {
+        assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
+    }
+
+    #[test]
+    fn dict_preserves_order_and_replaces_in_place() {
+        let mut d = Dict::new();
+        d.set(b"B".to_vec(), 1);
+        d.set(b"A".to_vec(), 2);
+        d.set(b"B".to_vec(), 3);
+        let keys: Vec<_> = d.iter().map(|(k, _)| k.clone()).collect();
+        assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]);
+        assert_eq!(d.int(b"B"), Some(3));
+    }
+}
diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs
new file mode 100644
index 000000000..807471312
--- /dev/null
+++ b/crates/cos/src/parser.rs
@@ -0,0 +1,514 @@
+//! Lexer and object parser (ISO 32000-2 §7.2–7.3), tolerant of common real-world damage.
+//!
+//! The parser works on a byte slice and a position; it never allocates for skipped data and
+//! never panics on malformed input (every index is bounds-checked). Depth is limited to defend
+//! against pathological nesting.
+
+use crate::CosError;
+use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
+
+const MAX_DEPTH: usize = 128;
+
+pub(crate) fn is_whitespace(b: u8) -> bool {
+    matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ')
+}
+
+pub(crate) fn is_delimiter(b: u8) -> bool {
+    matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
+}
+
+fn is_regular(b: u8) -> bool {
+    !is_whitespace(b) && !is_delimiter(b)
+}
+
+/// A cursor over PDF bytes.
+pub struct Lexer<'a> {
+    pub data: &'a [u8],
+    pub pos: usize,
+}
+
+impl<'a> Lexer<'a> {
+    pub fn new(data: &'a [u8], pos: usize) -> Self {
+        Self { data, pos: pos.min(data.len()) }
+    }
+
+    fn peek(&self) -> Option {
+        self.data.get(self.pos).copied()
+    }
+
+    /// Skip whitespace and comments.
+    pub fn skip_ws(&mut self) {
+        while let Some(b) = self.peek() {
+            if is_whitespace(b) {
+                self.pos += 1;
+            } else if b == b'%' {
+                while let Some(c) = self.peek() {
+                    if c == b'\n' || c == b'\r' {
+                        break;
+                    }
+                    self.pos += 1;
+                }
+            } else {
+                break;
+            }
+        }
+    }
+
+    /// Read a run of regular characters (a keyword or number).
+    pub fn token(&mut self) -> &'a [u8] {
+        let start = self.pos.min(self.data.len());
+        while self.peek().is_some_and(is_regular) {
+            self.pos += 1;
+        }
+        &self.data[start..self.pos]
+    }
+
+    /// `true` (and consumes it) if the next token is exactly `kw`.
+    pub fn eat_keyword(&mut self, kw: &[u8]) -> bool {
+        self.skip_ws();
+        let end = self.pos + kw.len();
+        if self.data.get(self.pos..end) == Some(kw) && self.data.get(end).is_none_or(|b| !is_regular(*b)) {
+            self.pos = end;
+            true
+        } else {
+            false
+        }
+    }
+
+    fn err(&self, what: &str) -> CosError {
+        CosError::Syntax { offset: self.pos, detail: what.to_string() }
+    }
+
+    /// Parse one direct object. References `N G R` are recognised.
+    pub fn object(&mut self) -> Result {
+        self.object_depth(0)
+    }
+
+    fn object_depth(&mut self, depth: usize) -> Result {
+        if depth > MAX_DEPTH {
+            return Err(self.err("nesting too deep"));
+        }
+        self.skip_ws();
+        let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) };
+        match b {
+            b'/' => {
+                self.pos += 1;
+                Ok(Object::Name(self.name_body()))
+            }
+            b'(' => {
+                self.pos += 1;
+                Ok(Object::String(PdfString { bytes: self.literal_string()?, hex: false }))
+            }
+            b'<' if self.data.get(self.pos + 1) == Some(&b'<') => {
+                self.pos += 2;
+                Ok(Object::Dict(self.dict_body(depth)?))
+            }
+            b'<' => {
+                self.pos += 1;
+                Ok(Object::String(PdfString { bytes: self.hex_string()?, hex: true }))
+            }
+            b'[' => {
+                self.pos += 1;
+                let mut items = Vec::new();
+                loop {
+                    self.skip_ws();
+                    match self.peek() {
+                        None => return Err(self.err("unterminated array")),
+                        Some(b']') => {
+                            self.pos += 1;
+                            break;
+                        }
+                        _ => {
+                            let before = self.pos;
+                            match self.object_depth(depth + 1) {
+                                Ok(o) => items.push(o),
+                                // Tolerate junk inside arrays by skipping one byte.
+                                Err(_) if self.pos == before => self.pos += 1,
+                                Err(e) => return Err(e),
+                            }
+                        }
+                    }
+                }
+                Ok(Object::Array(items))
+            }
+            b'+' | b'-' | b'.' | b'0'..=b'9' => self.number_or_ref(),
+            _ => {
+                let start = self.pos;
+                let tok = self.token();
+                match tok {
+                    b"true" => Ok(Object::Bool(true)),
+                    b"false" => Ok(Object::Bool(false)),
+                    b"null" => Ok(Object::Null),
+                    b"" => {
+                        self.pos = start + 1;
+                        Err(CosError::Syntax { offset: start, detail: format!("unexpected byte 0x{b:02x}") })
+                    }
+                    other => Err(CosError::Syntax { offset: start, detail: format!("unexpected keyword {:?}", String::from_utf8_lossy(other)) }),
+                }
+            }
+        }
+    }
+
+    fn name_body(&mut self) -> Vec {
+        let mut out = Vec::new();
+        while let Some(b) = self.peek() {
+            if !is_regular(b) {
+                break;
+            }
+            self.pos += 1;
+            if b == b'#'
+                && let (Some(h), Some(l)) = (self.data.get(self.pos).and_then(|c| hexval(*c)), self.data.get(self.pos + 1).and_then(|c| hexval(*c)))
+            {
+                out.push(h << 4 | l);
+                self.pos += 2;
+                continue;
+            }
+            out.push(b);
+        }
+        out
+    }
+
+    fn literal_string(&mut self) -> Result, CosError> {
+        let mut out = Vec::new();
+        let mut depth = 1usize;
+        while let Some(b) = self.peek() {
+            self.pos += 1;
+            match b {
+                b'(' => {
+                    depth += 1;
+                    out.push(b);
+                }
+                b')' => {
+                    depth -= 1;
+                    if depth == 0 {
+                        return Ok(out);
+                    }
+                    out.push(b);
+                }
+                b'\\' => {
+                    let Some(e) = self.peek() else { break };
+                    self.pos += 1;
+                    match e {
+                        b'n' => out.push(b'\n'),
+                        b'r' => out.push(b'\r'),
+                        b't' => out.push(b'\t'),
+                        b'b' => out.push(8),
+                        b'f' => out.push(12),
+                        b'(' | b')' | b'\\' => out.push(e),
+                        b'\r' => {
+                            if self.peek() == Some(b'\n') {
+                                self.pos += 1;
+                            }
+                        }
+                        b'\n' => {}
+                        b'0'..=b'7' => {
+                            let mut v = (e - b'0') as u32;
+                            for _ in 0..2 {
+                                match self.peek() {
+                                    Some(d @ b'0'..=b'7') => {
+                                        v = v * 8 + (d - b'0') as u32;
+                                        self.pos += 1;
+                                    }
+                                    _ => break,
+                                }
+                            }
+                            out.push((v & 0xFF) as u8);
+                        }
+                        other => out.push(other),
+                    }
+                }
+                // End-of-line markers inside strings are normalised to \n (§7.3.4.2).
+                b'\r' => {
+                    if self.peek() == Some(b'\n') {
+                        self.pos += 1;
+                    }
+                    out.push(b'\n');
+                }
+                other => out.push(other),
+            }
+        }
+        Err(self.err("unterminated string"))
+    }
+
+    fn hex_string(&mut self) -> Result, CosError> {
+        let mut out = Vec::new();
+        let mut hi: Option = None;
+        while let Some(b) = self.peek() {
+            self.pos += 1;
+            if b == b'>' {
+                if let Some(h) = hi {
+                    out.push(h << 4);
+                }
+                return Ok(out);
+            }
+            if is_whitespace(b) {
+                continue;
+            }
+            let Some(v) = hexval(b) else { return Err(self.err("invalid hex digit")) };
+            match hi.take() {
+                None => hi = Some(v),
+                Some(h) => out.push(h << 4 | v),
+            }
+        }
+        Err(self.err("unterminated hex string"))
+    }
+
+    fn dict_body(&mut self, depth: usize) -> Result {
+        let mut d = Dict::new();
+        loop {
+            self.skip_ws();
+            match self.peek() {
+                None => return Err(self.err("unterminated dictionary")),
+                Some(b'>') if self.data.get(self.pos + 1) == Some(&b'>') => {
+                    self.pos += 2;
+                    return Ok(d);
+                }
+                Some(b'/') => {
+                    self.pos += 1;
+                    let key = self.name_body();
+                    self.skip_ws();
+                    // A key directly followed by `>>` has no value: treat as null. (A following
+                    // `/` is a name *value*, so it cannot signal a missing value.)
+                    if self.peek() == Some(b'>') && self.data.get(self.pos + 1) == Some(&b'>') {
+                        d.set(key, Object::Null);
+                        continue;
+                    }
+                    let value = self.object_depth(depth + 1)?;
+                    d.set(key, value);
+                }
+                Some(_) => {
+                    // Tolerate garbage between entries (seen in damaged files): skip a token.
+                    let before = self.pos;
+                    let _ = self.object_depth(depth + 1);
+                    if self.pos == before {
+                        self.pos += 1;
+                    }
+                }
+            }
+        }
+    }
+
+    fn number_or_ref(&mut self) -> Result {
+        let start = self.pos;
+        let first = self.number()?;
+        // Look ahead for "G R" to form a reference.
+        if let Object::Int(n) = first
+            && n >= 0
+        {
+            let save = self.pos;
+            self.skip_ws();
+            if self.peek().is_some_and(|b| b.is_ascii_digit()) {
+                let gstart = self.pos;
+                let g = self.token();
+                if g.iter().all(u8::is_ascii_digit) && self.eat_keyword(b"R") {
+                    let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0);
+                    if n <= u32::MAX as i64 {
+                        return Ok(Object::Ref(ObjRef::new(n as u32, generation.min(u16::MAX as u32) as u16)));
+                    }
+                }
+                let _ = gstart;
+            }
+            self.pos = save;
+        }
+        let _ = start;
+        Ok(first)
+    }
+
+    fn number(&mut self) -> Result {
+        let start = self.pos;
+        let tok = self.token();
+        let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?;
+        if s.contains('.') {
+            // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
+            let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
+            let mut end = cleaned.len();
+            while end > 0 {
+                if let Ok(v) = cleaned[..end].parse::() {
+                    return Ok(Object::Real(v));
+                }
+                end -= 1;
+            }
+            return Ok(Object::Real(0.0));
+        }
+        match s.trim_start_matches('+').parse::() {
+            Ok(v) => Ok(Object::Int(v)),
+            Err(_) => {
+                // Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
+                s.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
+            }
+        }
+    }
+}
+
+fn hexval(b: u8) -> Option {
+    match b {
+        b'0'..=b'9' => Some(b - b'0'),
+        b'a'..=b'f' => Some(b - b'a' + 10),
+        b'A'..=b'F' => Some(b - b'A' + 10),
+        _ => None,
+    }
+}
+
+/// Parse an indirect object at `offset`: `N G obj  [stream…endstream] endobj`.
+/// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen).
+pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> {
+    let mut lx = Lexer::new(data, offset);
+    lx.skip_ws();
+    let num_tok = lx.token();
+    lx.skip_ws();
+    let gen_tok = lx.token();
+    let parse_u = |t: &[u8]| std::str::from_utf8(t).ok().and_then(|s| s.parse::().ok());
+    let (Some(num), Some(generation)) = (parse_u(num_tok), parse_u(gen_tok)) else {
+        return Err(CosError::Syntax { offset, detail: "expected object header".into() });
+    };
+    if !lx.eat_keyword(b"obj") {
+        return Err(CosError::Syntax { offset: lx.pos, detail: "expected 'obj'".into() });
+    }
+    let id = ObjRef::new(num.min(u32::MAX as u64) as u32, generation.min(u16::MAX as u64) as u16);
+    let obj = match lx.object() {
+        Ok(o) => o,
+        // "N G obj endobj" (empty) is treated as null.
+        Err(_) if lx.eat_keyword(b"endobj") => return Ok((id, Object::Null)),
+        Err(e) => return Err(e),
+    };
+    lx.skip_ws();
+    if let Object::Dict(dict) = &obj
+        && lx.eat_keyword(b"stream")
+    {
+        // The keyword is followed by CRLF or LF (a lone CR is tolerated).
+        match (data.get(lx.pos), data.get(lx.pos + 1)) {
+            (Some(b'\r'), Some(b'\n')) => lx.pos += 2,
+            (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
+            _ => {}
+        }
+        let start = lx.pos;
+        let declared = match dict.get(b"Length") {
+            Some(Object::Int(n)) => Some(*n),
+            Some(Object::Ref(r)) => resolve_length(*r),
+            _ => None,
+        };
+        let valid = |len: i64| -> bool {
+            if len < 0 {
+                return false;
+            }
+            let end = start.saturating_add(len as usize);
+            if end > data.len() {
+                return false;
+            }
+            let mut after = Lexer::new(data, end);
+            after.eat_keyword(b"endstream")
+        };
+        let len = match declared {
+            Some(l) if valid(l) => l as usize,
+            // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
+            _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start,
+        };
+        let raw = data[start..start + len].to_vec();
+        let mut dict = dict.clone();
+        dict.set(b"Length".to_vec(), Object::Int(raw.len() as i64));
+        return Ok((id, Object::Stream(Stream::from_raw(dict, raw))));
+    }
+    Ok((id, obj))
+}
+
+/// Offset where stream data ends (before the EOL preceding "endstream").
+fn find_endstream(data: &[u8], from: usize) -> Option {
+    let hay = data.get(from..)?;
+    let at = hay.windows(9).position(|w| w == b"endstream")? + from;
+    let mut end = at;
+    if end > from && data[end - 1] == b'\n' {
+        end -= 1;
+    }
+    if end > from && data[end - 1] == b'\r' {
+        end -= 1;
+    }
+    Some(end)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn obj(s: &str) -> Object {
+        Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
+    }
+
+    #[test]
+    fn scalars() {
+        assert_eq!(obj("true"), Object::Bool(true));
+        assert_eq!(obj("null"), Object::Null);
+        assert_eq!(obj("  -17 "), Object::Int(-17));
+        assert_eq!(obj("+42"), Object::Int(42));
+        assert_eq!(obj("-.5"), Object::Real(-0.5));
+        assert_eq!(obj("4."), Object::Real(4.0));
+        assert_eq!(obj("99999999999999999999"), Object::Real(1e20));
+        assert_eq!(obj("12 0 R"), Object::Ref(ObjRef::new(12, 0)));
+        assert_eq!(obj("12 0 obj").as_int(), Some(12), "12 followed by 0 obj is not a reference");
+    }
+
+    #[test]
+    fn names_decode_hex_escapes() {
+        assert_eq!(obj("/A#20B"), Object::Name(b"A B".to_vec()));
+        assert_eq!(obj("/Adobe#"), Object::Name(b"Adobe#".to_vec()));
+        assert_eq!(obj("/"), Object::Name(Vec::new()));
+    }
+
+    #[test]
+    fn literal_strings() {
+        let s = |x: &str| obj(x).as_string().expect("string").bytes.clone();
+        assert_eq!(s("(a (nested) b)"), b"a (nested) b");
+        assert_eq!(s(r"(\n\t\\\(\))"), b"\n\t\\()");
+        assert_eq!(s(r"(\101\1011)"), b"AA1");
+        assert_eq!(s("(line\\\ncontinued)"), b"linecontinued");
+        assert_eq!(s("(cr\r\nlf)"), b"cr\nlf");
+    }
+
+    #[test]
+    fn hex_strings() {
+        let s = obj("<48 65 6C6c6f7>");
+        assert_eq!(s.as_string().unwrap().bytes, b"Hellop");
+        assert!(s.as_string().unwrap().hex);
+    }
+
+    #[test]
+    fn arrays_and_dicts() {
+        let o = obj("<< /Type /Page /Kids [1 0 R 2 0 R] /Box [0 0 612.5 792] /Nested << /A true >> /Empty >>");
+        let d = o.as_dict().unwrap();
+        assert_eq!(d.name(b"Type"), Some(&b"Page"[..]));
+        assert_eq!(d.get(b"Kids").unwrap().as_array().unwrap().len(), 2);
+        assert_eq!(d.get(b"Box").unwrap().as_array().unwrap()[2], Object::Real(612.5));
+        assert_eq!(d.get(b"Empty"), Some(&Object::Null));
+    }
+
+    #[test]
+    fn comments_are_whitespace() {
+        assert_eq!(obj("% hello\n  [1 % two\n 3]"), Object::Array(vec![Object::Int(1), Object::Int(3)]));
+    }
+
+    #[test]
+    fn indirect_stream_with_wrong_length_recovers() {
+        let data = b"7 0 obj\n<< /Length 999 >>\nstream\nHELLO\nendstream\nendobj\n";
+        let (id, o) = parse_indirect(data, 0, &|_| None).unwrap();
+        assert_eq!(id, ObjRef::new(7, 0));
+        let Object::Stream(s) = o else { panic!("stream expected") };
+        assert_eq!(s.raw.as_slice(), b"HELLO");
+        assert_eq!(s.dict.int(b"Length"), Some(5));
+    }
+
+    #[test]
+    fn indirect_length_is_resolved() {
+        let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
+        let (_, o) = parse_indirect(data, 0, &|r| (r.num == 8).then_some(2)).unwrap();
+        let Object::Stream(s) = o else { panic!() };
+        assert_eq!(s.raw.as_slice(), b"AB");
+    }
+
+    #[test]
+    fn garbage_never_panics() {
+        for input in ["<<", "[", "(", "<", "<< /A", "[1 2", ")", "]", ">>", "<<>>>", "1 0", "\0\0", "/A#", "", "((("] {
+            let _ = Lexer::new(input.as_bytes(), 0).object();
+        }
+        let deep = "[".repeat(10_000);
+        assert!(Lexer::new(deep.as_bytes(), 0).object().is_err());
+    }
+}
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
new file mode 100644
index 000000000..aa2710597
--- /dev/null
+++ b/crates/cos/src/writer.rs
@@ -0,0 +1,386 @@
+//! Serialization and saving (ISO 32000-2 §7.5).
+//!
+//! * `write_incremental` appends the edited objects, a cross-reference section in the same style
+//!   as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
+//!   never modified, which keeps digital signatures over earlier revisions valid.
+//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
+//!   classic cross-reference table — the "Save As" / garbage-collecting path.
+
+use std::collections::{BTreeMap, HashMap, VecDeque};
+use std::io::Write;
+
+use crate::CosError;
+use crate::document::Document;
+use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
+
+/// Serialize one object. Deterministic: same object → same bytes.
+pub fn serialize(o: &Object, out: &mut Vec) {
+    match o {
+        Object::Null => out.extend_from_slice(b"null"),
+        Object::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
+        Object::Int(i) => {
+            let _ = write!(out, "{i}");
+        }
+        Object::Real(r) => write_real(*r, out),
+        Object::String(s) => write_string(s, out),
+        Object::Name(n) => write_name(n, out),
+        Object::Array(a) => {
+            out.push(b'[');
+            for (i, x) in a.iter().enumerate() {
+                if i > 0 {
+                    out.push(b' ');
+                }
+                serialize(x, out);
+            }
+            out.push(b']');
+        }
+        Object::Dict(d) => write_dict(d, out),
+        Object::Stream(s) => write_stream(s, out),
+        Object::Ref(r) => {
+            let _ = write!(out, "{} {} R", r.num, r.generation);
+        }
+    }
+}
+
+fn write_real(r: f64, out: &mut Vec) {
+    if !r.is_finite() {
+        out.push(b'0');
+        return;
+    }
+    if r.fract() == 0.0 && r.abs() < 1e15 {
+        let _ = write!(out, "{}", r as i64);
+        return;
+    }
+    // Plain decimal notation (no exponents, §7.3.3), trimmed.
+    let s = format!("{r:.6}");
+    let s = s.trim_end_matches('0').trim_end_matches('.');
+    out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() });
+}
+
+fn write_name(n: &[u8], out: &mut Vec) {
+    out.push(b'/');
+    for &b in n {
+        let regular = (b'!'..=b'~').contains(&b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'#');
+        if regular {
+            out.push(b);
+        } else {
+            let _ = write!(out, "#{b:02X}");
+        }
+    }
+}
+
+fn write_string(s: &PdfString, out: &mut Vec) {
+    let binary = s.bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count();
+    if s.hex || binary * 4 > s.bytes.len().max(1) {
+        out.push(b'<');
+        for b in &s.bytes {
+            let _ = write!(out, "{b:02X}");
+        }
+        out.push(b'>');
+        return;
+    }
+    out.push(b'(');
+    for &b in &s.bytes {
+        match b {
+            b'(' | b')' | b'\\' => {
+                out.push(b'\\');
+                out.push(b);
+            }
+            b'\n' => out.extend_from_slice(b"\\n"),
+            b'\r' => out.extend_from_slice(b"\\r"),
+            b'\t' => out.extend_from_slice(b"\\t"),
+            0x08 => out.extend_from_slice(b"\\b"),
+            0x0C => out.extend_from_slice(b"\\f"),
+            b' '..=b'~' => out.push(b),
+            _ => {
+                let _ = write!(out, "\\{b:03o}");
+            }
+        }
+    }
+    out.push(b')');
+}
+
+fn write_dict(d: &Dict, out: &mut Vec) {
+    out.extend_from_slice(b"<<");
+    for (k, v) in d.iter() {
+        write_name(k, out);
+        out.push(b' ');
+        serialize(v, out);
+    }
+    out.extend_from_slice(b">>");
+}
+
+fn write_stream(s: &Stream, out: &mut Vec) {
+    let mut d = s.dict.clone();
+    d.set(b"Length".to_vec(), Object::Int(s.raw.len() as i64));
+    write_dict(&d, out);
+    out.extend_from_slice(b"\nstream\n");
+    out.extend_from_slice(&s.raw);
+    out.extend_from_slice(b"\nendstream");
+}
+
+fn write_indirect(num: u32, generation: u16, o: &Object, out: &mut Vec) {
+    let _ = writeln!(out, "{num} {generation} obj");
+    serialize(o, out);
+    out.extend_from_slice(b"\nendobj\n");
+}
+
+/// Options for saving.
+#[derive(Clone, Debug)]
+pub struct SaveOptions {
+    /// Replace or add `/Info /ModDate` with this PDF date string (e.g. from `pdf_date`).
+    pub mod_date: Option,
+    /// Seed for a new file identifier when the document has none (deterministic in tests).
+    pub id_seed: u64,
+}
+
+impl Default for SaveOptions {
+    fn default() -> Self {
+        Self { mod_date: None, id_seed: 0x5052_494E_5443_5241 }
+    }
+}
+
+/// Append the document's edits to its original bytes (incremental update, §7.5.6).
+///
+/// A document that had to be reconstructed (no usable cross-reference chain) cannot be updated
+/// incrementally — other readers would follow the broken chain — so it is rewritten in full,
+/// which also repairs the file (what users expect after "the file was damaged and repaired").
+pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, CosError> {
+    if doc.revisions().is_empty() {
+        return write_full(doc, opts);
+    }
+    let mut doc = doc.clone();
+    stamp_mod_date(&mut doc, opts);
+    let original = doc.bytes().clone();
+    let mut out = Vec::with_capacity(original.len() + 4096);
+    out.extend_from_slice(&original);
+    if !matches!(out.last(), Some(b'\n' | b'\r')) {
+        out.push(b'\n');
+    }
+    let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use)
+    for (num, generation, obj) in doc.overlay_entries().collect::>() {
+        match obj {
+            Some(o) => {
+                offsets.insert(num, (out.len() as u64, generation, true));
+                write_indirect(num, generation, &o, &mut out);
+            }
+            None => {
+                offsets.insert(num, (0, generation, false));
+            }
+        }
+    }
+    let prev = doc.revisions().last().map(|r| r.xref_offset);
+    let as_stream = doc.revisions().last().is_some_and(|r| r.is_stream);
+    let mut trailer = doc.trailer().clone();
+    ensure_id(&mut trailer, opts, &original);
+    if let Some(p) = prev {
+        trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
+    }
+    let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
+    if as_stream {
+        write_xref_stream(&mut out, &mut trailer, &offsets, size, &[])?;
+    } else {
+        trailer.set(b"Size".to_vec(), Object::Int(size as i64));
+        let xref_at = out.len();
+        write_xref_table(&mut out, &offsets, prev.is_none());
+        out.extend_from_slice(b"trailer\n");
+        write_dict(&trailer, &mut out);
+        let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
+    }
+    Ok(out)
+}
+
+/// Write only reachable objects, renumbered from 1, with a classic cross-reference table.
+pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosError> {
+    let mut doc = doc.clone();
+    stamp_mod_date(&mut doc, opts);
+    let trailer_in = doc.trailer().clone();
+    // Breadth-first walk from the trailer, assigning new numbers in visit order.
+    let mut map: HashMap = HashMap::new();
+    let mut order: Vec = Vec::new();
+    let mut queue: VecDeque = VecDeque::new();
+    let visit_refs = |o: &Object, queue: &mut VecDeque| collect_refs(o, &mut |r| queue.push_back(r));
+    for key in [&b"Root"[..], b"Info"] {
+        if let Some(r) = trailer_in.reference(key) {
+            queue.push_back(r);
+        }
+    }
+    while let Some(r) = queue.pop_front() {
+        if map.contains_key(&r) {
+            continue;
+        }
+        let o = doc.get(r);
+        if matches!(*o, Object::Null) {
+            continue; // dangling reference: written as null by renumbering below
+        }
+        map.insert(r, (order.len() + 1) as u32);
+        order.push(r);
+        visit_refs(&o, &mut queue);
+    }
+    let mut out = Vec::new();
+    let _ = writeln!(out, "%PDF-{}\n%\u{e2}\u{e3}\u{cf}\u{d3}", doc.version().chars().take(3).collect::());
+    // The binary comment must be raw high bytes, not UTF-8.
+    out.truncate(out.len() - "\u{e2}\u{e3}\u{cf}\u{d3}\n".len());
+    out.extend_from_slice(&[0xE2, 0xE3, 0xCF, 0xD3, b'\n']);
+    let mut offsets: BTreeMap = BTreeMap::new();
+    for (i, r) in order.iter().enumerate() {
+        let num = (i + 1) as u32;
+        let o = renumber(&doc.get(*r), &map);
+        offsets.insert(num, (out.len() as u64, 0, true));
+        write_indirect(num, 0, &o, &mut out);
+    }
+    let mut trailer = Dict::new();
+    trailer.set(b"Size".to_vec(), Object::Int(order.len() as i64 + 1));
+    for key in [&b"Root"[..], b"Info"] {
+        if let Some(r) = trailer_in.reference(key).and_then(|r| map.get(&r)) {
+            trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0)));
+        }
+    }
+    if let Some(id) = trailer_in.get(b"ID") {
+        trailer.set(b"ID".to_vec(), id.clone());
+    }
+    ensure_id(&mut trailer, opts, doc.bytes());
+    let xref_at = out.len();
+    write_xref_table(&mut out, &offsets, true);
+    out.extend_from_slice(b"trailer\n");
+    write_dict(&trailer, &mut out);
+    let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
+    Ok(out)
+}
+
+fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) {
+    match o {
+        Object::Ref(r) => f(*r),
+        Object::Array(a) => a.iter().for_each(|x| collect_refs(x, f)),
+        Object::Dict(d) => d.iter().for_each(|(_, v)| collect_refs(v, f)),
+        Object::Stream(s) => s.dict.iter().for_each(|(_, v)| collect_refs(v, f)),
+        _ => {}
+    }
+}
+
+fn renumber(o: &Object, map: &HashMap) -> Object {
+    match o {
+        Object::Ref(r) => map.get(r).map(|n| Object::Ref(ObjRef::new(*n, 0))).unwrap_or(Object::Null),
+        Object::Array(a) => Object::Array(a.iter().map(|x| renumber(x, map)).collect()),
+        Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), renumber(v, map))).collect()),
+        Object::Stream(s) => Object::Stream(Stream { dict: s.dict.iter().map(|(k, v)| (k.clone(), renumber(v, map))).collect(), raw: s.raw.clone() }),
+        other => other.clone(),
+    }
+}
+
+/// Classic xref table with one subsection per run of consecutive numbers (§7.5.4).
+fn write_xref_table(out: &mut Vec, offsets: &BTreeMap, include_zero: bool) {
+    out.extend_from_slice(b"xref\n");
+    let mut all: Vec<(u32, (u64, u16, bool))> = offsets.iter().map(|(k, v)| (*k, *v)).collect();
+    if include_zero {
+        all.insert(0, (0, (0, 65535, false)));
+    }
+    let mut i = 0;
+    while i < all.len() {
+        let start = all[i].0;
+        let mut j = i;
+        while j + 1 < all.len() && all[j + 1].0 == all[j].0 + 1 {
+            j += 1;
+        }
+        let _ = writeln!(out, "{} {}", start, j - i + 1);
+        for (_, (off, generation, used)) in &all[i..=j] {
+            let _ = writeln!(out, "{:010} {:05} {} ", off, generation, if *used { 'n' } else { 'f' });
+        }
+        i = j + 1;
+    }
+}
+
+/// A cross-reference stream section (for files whose last section is a stream, §7.5.8).
+fn write_xref_stream(
+    out: &mut Vec,
+    trailer: &mut Dict,
+    offsets: &BTreeMap,
+    size: u32,
+    _extra: &[u32],
+) -> Result<(), CosError> {
+    let xref_num = size;
+    let xref_at = out.len() as u64;
+    let mut rows: BTreeMap =
+        offsets.iter().map(|(n, (o, g, used))| (*n, if *used { (1, *o, *g) } else { (0, 0, *g) })).collect();
+    rows.insert(xref_num, (1, xref_at, 0));
+    let mut data = Vec::new();
+    let mut index = Vec::new();
+    let nums: Vec = rows.keys().copied().collect();
+    let mut i = 0;
+    while i < nums.len() {
+        let mut j = i;
+        while j + 1 < nums.len() && nums[j + 1] == nums[j] + 1 {
+            j += 1;
+        }
+        index.push(Object::Int(nums[i] as i64));
+        index.push(Object::Int((j - i + 1) as i64));
+        i = j + 1;
+    }
+    for (t, a, b) in rows.values() {
+        data.push(*t);
+        data.extend_from_slice(&(*a as u32).to_be_bytes());
+        data.extend_from_slice(&b.to_be_bytes());
+    }
+    let mut d = trailer.clone();
+    d.set(b"Type".to_vec(), Object::name("XRef"));
+    d.set(b"Size".to_vec(), Object::Int(xref_num as i64 + 1));
+    d.set(b"W".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(4), Object::Int(2)]));
+    d.set(b"Index".to_vec(), Object::Array(index));
+    let stream = Stream::flate(d, &data);
+    write_indirect(xref_num, 0, &Object::Stream(stream), out);
+    let _ = write!(out, "startxref\n{xref_at}\n%%EOF\n");
+    trailer.set(b"Size".to_vec(), Object::Int(xref_num as i64 + 1));
+    Ok(())
+}
+
+fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) {
+    let Some(date) = &opts.mod_date else { return };
+    let date = Object::String(PdfString::literal(date.as_bytes().to_vec()));
+    match doc.trailer().get(b"Info").cloned() {
+        Some(Object::Ref(r)) => {
+            let _ = doc.update_dict(r, |d| d.set(b"ModDate".to_vec(), date.clone()));
+        }
+        _ => {
+            let mut info = Dict::new();
+            info.set(b"ModDate".to_vec(), date);
+            let r = doc.add(info);
+            doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
+        }
+    }
+}
+
+/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4).
+fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
+    if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) {
+        return;
+    }
+    // FNV-1a over a sample of the content and the seed: stable, dependency-free.
+    let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed;
+    for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) {
+        h ^= *b as u64;
+        h = h.wrapping_mul(0x0100_0000_01b3);
+    }
+    let mut id = Vec::with_capacity(16);
+    id.extend_from_slice(&h.to_be_bytes());
+    id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes());
+    let s = Object::String(PdfString { bytes: id, hex: true });
+    trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
+}
+
+/// A PDF date string for "now" given seconds since the Unix epoch (UTC).
+pub fn pdf_date(unix_secs: i64) -> String {
+    let days = unix_secs.div_euclid(86_400);
+    let secs = unix_secs.rem_euclid(86_400);
+    // Civil-from-days (Howard Hinnant's algorithm).
+    let z = days + 719_468;
+    let era = z.div_euclid(146_097);
+    let doe = z - era * 146_097;
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+    let y = yoe + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let d = doy - (153 * mp + 2) / 5 + 1;
+    let m = if mp < 10 { mp + 3 } else { mp - 9 };
+    let y = if m <= 2 { y + 1 } else { y };
+    format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
+}
diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/corpus.rs
new file mode 100644
index 000000000..c2cbf1bcb
--- /dev/null
+++ b/crates/cos/tests/corpus.rs
@@ -0,0 +1,101 @@
+//! Corpus parity: every file hayro-syntax can open must also open with our reader, agree on the
+//! page count, and survive an incremental save that touches the catalog (the appended revision
+//! must be readable by both readers and leave the original bytes untouched).
+//!
+//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches it); otherwise
+//! it is a no-op so CI without the corpus stays green. Run with `--ignored --nocapture`.
+
+use std::sync::Arc;
+
+use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+
+/// Leaf pages reachable from the catalog (cycle-safe), like viewers count them.
+fn page_count(doc: &Document) -> Option {
+    let root = doc.get(doc.root()?);
+    let mut stack = vec![root.as_dict()?.get(b"Pages")?.clone()];
+    let (mut seen, mut n) = (std::collections::HashSet::new(), 0);
+    while let Some(o) = stack.pop() {
+        if let Object::Ref(r) = o
+            && !seen.insert(r)
+        {
+            continue;
+        }
+        let node = doc.resolve(&o);
+        let Some(d) = node.as_dict() else { continue };
+        match d.get(b"Kids").map(|k| doc.resolve(k)) {
+            Some(k) if d.name(b"Type") != Some(b"Page") => stack.extend(k.as_array().cloned().unwrap_or_default().into_iter().rev()),
+            _ => n += 1,
+        }
+    }
+    Some(n)
+}
+
+#[test]
+#[ignore = "needs corpus/; run with --ignored"]
+fn corpus_parity() {
+    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs/test/pdfs");
+    let Ok(entries) = std::fs::read_dir(&dir) else {
+        eprintln!("corpus not present at {}", dir.display());
+        return;
+    };
+    let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
+    files.sort();
+    let (mut checked, mut encrypted, mut failures) = (0, 0, Vec::new());
+    for path in &files {
+        let bytes = Arc::new(std::fs::read(path).unwrap());
+        let Ok(reference) = std::panic::catch_unwind(|| hayro_syntax::Pdf::new(bytes.clone())) else { continue };
+        let Ok(reference) = reference else { continue }; // hayro can't open it either: not a parity case
+        let name = path.file_name().unwrap().to_string_lossy().into_owned();
+        if std::env::var("COS_ONLY").is_ok_and(|o| o != name) {
+            continue;
+        }
+        if std::env::var_os("COS_TRACE").is_some() {
+            eprintln!("{name}");
+        }
+        let expected = reference.pages().len();
+        let result = std::panic::catch_unwind(|| -> Result<(), String> {
+            let mut doc = match Document::open(bytes.clone()) {
+                Ok(d) => d,
+                Err(printcraft_cos::CosError::Encrypted) => return Err("encrypted".into()),
+                Err(e) => return Err(format!("open: {e}")),
+            };
+            // hayro may repair a broken /Count; compare against the leaf walk count loosely.
+            if page_count(&doc) != Some(expected) && expected > 0 {
+                return Err(format!("page count {:?} vs hayro {expected}", page_count(&doc)));
+            }
+            let root = doc.root().ok_or("no root")?;
+            doc.update_dict(root, |d| d.set(b"PrintCraftTest".to_vec(), Object::Bool(true))).map_err(|e| e.to_string())?;
+            let saved = write_incremental(&doc, &SaveOptions::default()).map_err(|e| format!("save: {e}"))?;
+            // Reconstructed files are rewritten in full; everything else must keep its bytes.
+            if !doc.revisions().is_empty() && saved.get(..bytes.len()) != Some(&bytes[..]) {
+                return Err("prefix modified".into());
+            }
+            let again = Document::open(Arc::new(saved.clone())).map_err(|e| format!("reopen: {e}"))?;
+            let flag = again.get(again.root().ok_or("no root after save")?).as_dict().and_then(|d| d.get(b"PrintCraftTest").cloned());
+            if flag != Some(Object::Bool(true)) {
+                return Err("edit lost after reopen".into());
+            }
+            let hay = hayro_syntax::Pdf::new(Arc::new(saved)).map_err(|e| format!("hayro reopen: {e:?}"))?;
+            if hay.pages().len() != expected {
+                return Err(format!("hayro sees {} pages after save, expected {expected}", hay.pages().len()));
+            }
+            let full = write_full(&doc, &SaveOptions::default()).map_err(|e| format!("full: {e}"))?;
+            let hay = hayro_syntax::Pdf::new(Arc::new(full)).map_err(|e| format!("hayro full: {e:?}"))?;
+            if hay.pages().len() != expected {
+                return Err(format!("hayro sees {} pages after full save, expected {expected}", hay.pages().len()));
+            }
+            Ok(())
+        });
+        match result {
+            Ok(Ok(())) => checked += 1,
+            Ok(Err(e)) if e == "encrypted" => encrypted += 1,
+            Ok(Err(e)) => failures.push(format!("{name}: {e}")),
+            Err(_) => failures.push(format!("{name}: PANIC")),
+        }
+    }
+    eprintln!("cos corpus parity: {checked} ok, {encrypted} encrypted (skipped), {} failures", failures.len());
+    for f in &failures {
+        eprintln!("  {f}");
+    }
+    assert!(failures.iter().all(|f| !f.ends_with("PANIC")), "no panics");
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ebeff37d6..fe70e6980 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -10,3 +10,6 @@ workspace = true
 
 [dependencies]
 printcraft-render = { workspace = true }
+printcraft-cos = { workspace = true }
+printcraft-organize = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 2db3584fb..eaeef388e 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1,32 +1,174 @@
 //! printcraft-engine — the façade every frontend talks to (architecture §4).
 //!
-//! This first slice holds open documents and the tool catalogue. The command registry, history
-//! and jobs arrive in M4; the types here are shaped so that the UI never touches rendering or
-//! parsing crates directly.
+//! Holds open documents, their edit history and the tool catalogue. Frontends never touch the
+//! parsing, rendering or editing crates directly.
+//!
+//! **Editing model.** Each document keeps a `printcraft_cos::Document` (the object graph with a
+//! copy-on-write overlay of edits). An edit runs on a clone, and on success the previous state is
+//! pushed onto the undo stack (clones share all unchanged data, so this is cheap). After every
+//! edit the *working file* is produced by an incremental write — original bytes plus one
+//! appended revision — and the view is refreshed from it, so what you see is exactly what Save
+//! will write. Saving rebases onto the written bytes, so the next save appends only new edits.
 
 pub mod catalog;
 
 use std::sync::Arc;
 
-use printcraft_render::{DocInfo, OpenError, RenderPool, inspect};
+use printcraft_cos::{SaveOptions, write_full, write_incremental};
+use printcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
 
 /// Stable identifier of an open document within a session.
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 pub struct DocId(pub u64);
 
+/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
+const MAX_UNDO: usize = 100;
+
+/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
+#[derive(Clone)]
+struct Editor {
+    cos: printcraft_cos::Document,
+    undo: Vec<(String, printcraft_cos::Document)>,
+    redo: Vec<(String, printcraft_cos::Document)>,
+}
+
 pub struct Document {
     pub id: DocId,
     pub name: String,
     pub path: Option,
+    /// The working file: what Save writes and what is displayed.
     pub bytes: Arc>,
     pub info: DocInfo,
     pub renderer: RenderPool,
+    /// The password the document was opened with (needed to read attachments, etc.).
+    pub password: Option,
+    /// `true` when there are edits that have not been saved.
+    pub dirty: bool,
+    /// Why the document cannot be edited (e.g. encryption), if so.
+    pub read_only_reason: Option,
+    editor: Option,
+    config: RenderConfig,
+}
+
+impl Document {
+    pub fn can_undo(&self) -> Option<&str> {
+        self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _)| l.as_str())
+    }
+
+    pub fn can_redo(&self) -> Option<&str> {
+        self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _)| l.as_str())
+    }
+
+    pub fn editable(&self) -> bool {
+        self.editor.is_some()
+    }
+
+    /// Current value of a document-information entry (Title, Author, …).
+    pub fn info_value(&self, key: &str) -> Option {
+        self.editor.as_ref().and_then(|e| printcraft_organize::info(&e.cos, key))
+    }
+
+    /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
+    pub fn repair_log(&self) -> Vec {
+        self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
+    }
+}
+
+fn render_threads() -> usize {
+    std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
+}
+
+/// Edits that can be applied to a document. Page indices are 0-based.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Edit {
+    RotatePages {
+        pages: Vec,
+        degrees: i64,
+    },
+    DeletePages {
+        pages: Vec,
+    },
+    MovePages {
+        pages: Vec,
+        to: usize,
+    },
+    InsertBlankPage {
+        at: usize,
+        width: f64,
+        height: f64,
+    },
+    SetInfo {
+        key: String,
+        value: String,
+    },
+    /// Several edits applied as one undoable step (all or nothing).
+    Batch {
+        label: String,
+        edits: Vec,
+    },
+}
+
+impl Edit {
+    /// Label for the Edit menu and history ("Undo Rotate pages").
+    pub fn label(&self) -> String {
+        match self {
+            Edit::RotatePages { pages, .. } => plural("Rotate page", pages.len()),
+            Edit::DeletePages { pages } => plural("Delete page", pages.len()),
+            Edit::MovePages { pages, .. } => plural("Move page", pages.len()),
+            Edit::InsertBlankPage { .. } => "Insert blank page".into(),
+            Edit::SetInfo { key, .. } => format!("Change {key}"),
+            Edit::Batch { label, .. } => label.clone(),
+        }
+    }
+}
+
+/// Perform an edit on a working copy (the caller discards it on error).
+fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditError> {
+    match edit {
+        Edit::RotatePages { pages, degrees } => printcraft_organize::rotate_pages(doc, pages, *degrees)?,
+        Edit::DeletePages { pages } => printcraft_organize::delete_pages(doc, pages)?,
+        Edit::MovePages { pages, to } => printcraft_organize::move_pages(doc, pages, *to)?,
+        Edit::InsertBlankPage { at, width, height } => {
+            printcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
+        }
+        Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?,
+        Edit::Batch { edits, .. } => {
+            for e in edits {
+                run_edit(doc, e)?;
+            }
+        }
+    }
+    Ok(())
+}
+
+fn plural(s: &str, n: usize) -> String {
+    if n == 1 { s.to_string() } else { format!("{s}s") }
+}
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum EditError {
+    #[error("no such document")]
+    NoDocument,
+    #[error("this document can't be edited: {0}")]
+    ReadOnly(String),
+    #[error("{0}")]
+    Organize(#[from] printcraft_organize::OrganizeError),
+    #[error("the edited document could not be written: {0}")]
+    Write(String),
+    #[error("the edited document could not be reopened: {0}")]
+    Reopen(String),
+    #[error("nothing to undo")]
+    NothingToUndo,
+    #[error("nothing to redo")]
+    NothingToRedo,
 }
 
 #[derive(Default)]
 pub struct Session {
     docs: Vec,
     next_id: u64,
+    /// Seconds since the Unix epoch, injected so saves are deterministic in tests.
+    clock: Option i64>,
 }
 
 impl Session {
@@ -34,18 +176,177 @@ impl Session {
         Self::default()
     }
 
+    /// Use a fixed clock (tests) instead of the system time.
+    pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
+        self.clock = Some(clock);
+        self
+    }
+
+    fn now(&self) -> Option {
+        if let Some(c) = self.clock {
+            return Some(c());
+        }
+        #[cfg(not(target_arch = "wasm32"))]
+        return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64);
+        #[cfg(target_arch = "wasm32")]
+        return None;
+    }
+
     /// Open a document from bytes. Rendering starts lazily when pages are requested.
-    pub fn open(&mut self, name: impl Into, path: Option, bytes: Vec) -> Result {
-        let bytes = Arc::new(bytes);
-        let info = inspect(bytes.clone())?;
-        let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1;
-        let renderer = RenderPool::new(bytes.clone(), threads);
+    ///
+    /// `password` is tried as either the user or owner password when the file is encrypted.
+    pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result {
+        let info = inspect(bytes.clone(), password)?;
+        let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
+        let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
+        let (editor, read_only_reason) = match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
+            Ok(Ok(cos)) => (Some(Editor { cos, undo: Vec::new(), redo: Vec::new() }), None),
+            Ok(Err(e)) => (None, Some(e.to_string())),
+            Err(_) => (None, Some("the document structure could not be read for editing".into())),
+        };
         self.next_id += 1;
         let id = DocId(self.next_id);
-        self.docs.push(Document { id, name: name.into(), path, bytes, info, renderer });
+        self.docs.push(Document {
+            id,
+            name: name.into(),
+            path,
+            bytes,
+            info,
+            renderer,
+            password: password.map(str::to_owned),
+            dirty: false,
+            read_only_reason,
+            editor,
+            config,
+        });
         Ok(id)
     }
 
+    fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
+        self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument)
+    }
+
+    /// Apply an edit. On success the previous state is undoable and the view data is refreshed.
+    pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
+        let doc = self.doc_mut(id)?;
+        let reason = doc.read_only_reason.clone().unwrap_or_default();
+        let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?;
+        let mut next = editor.cos.clone();
+        run_edit(&mut next, &edit)?;
+        let previous = std::mem::replace(&mut editor.cos, next);
+        editor.undo.push((edit.label(), previous));
+        if editor.undo.len() > MAX_UNDO {
+            editor.undo.remove(0);
+        }
+        editor.redo.clear();
+        if let Err(e) = Self::refresh(doc) {
+            // Roll back: the edit produced something we cannot display.
+            if let Some(ed) = doc.editor.as_mut()
+                && let Some((_, prev)) = ed.undo.pop()
+            {
+                ed.cos = prev;
+            }
+            let _ = Self::refresh(doc);
+            return Err(e);
+        }
+        doc.dirty = true;
+        Ok(())
+    }
+
+    pub fn undo(&mut self, id: DocId) -> Result {
+        let doc = self.doc_mut(id)?;
+        let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
+        let (label, prev) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
+        let current = std::mem::replace(&mut editor.cos, prev);
+        editor.redo.push((label.clone(), current));
+        Self::refresh(doc)?;
+        doc.dirty = true;
+        Ok(label)
+    }
+
+    pub fn redo(&mut self, id: DocId) -> Result {
+        let doc = self.doc_mut(id)?;
+        let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
+        let (label, next) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
+        let current = std::mem::replace(&mut editor.cos, next);
+        editor.undo.push((label.clone(), current));
+        Self::refresh(doc)?;
+        doc.dirty = true;
+        Ok(label)
+    }
+
+    /// Rebuild working bytes, inspection and renderer from the current edit state.
+    fn refresh(doc: &mut Document) -> Result<(), EditError> {
+        let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
+        let bytes = if editor.cos.is_modified() {
+            Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
+        } else {
+            editor.cos.bytes().clone()
+        };
+        let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
+        // Keep the user's layer choices where the layers still exist.
+        let mut info = info;
+        for l in &mut info.layers {
+            if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) {
+                l.visible = old.visible;
+            }
+        }
+        doc.info = info;
+        doc.bytes = bytes.clone();
+        doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
+        Ok(())
+    }
+
+    /// The bytes to write for Save: an incremental update of the file as opened/last saved,
+    /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully.
+    pub fn save_bytes(&self, id: DocId) -> Result>, EditError> {
+        let doc = self.get(id).ok_or(EditError::NoDocument)?;
+        let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
+        if !editor.cos.is_modified() {
+            return Ok(editor.cos.bytes().clone());
+        }
+        let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
+        write_incremental(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
+    }
+
+    /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork).
+    pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> {
+        let doc = self.get(id).ok_or(EditError::NoDocument)?;
+        let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
+        let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
+        write_full(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
+    }
+
+    /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file
+    /// so the next save appends only newer edits. Undo history is kept.
+    pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> {
+        let doc = self.doc_mut(id)?;
+        if let Some(editor) = doc.editor.as_mut() {
+            editor.cos = printcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
+        }
+        if let Some(p) = path {
+            doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
+            doc.path = Some(p);
+        }
+        doc.dirty = false;
+        Self::refresh(doc)
+    }
+
+    /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed;
+    /// callers must drop cached rasters and text for the document.
+    pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool {
+        let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
+        let Some(l) = doc.info.layers.get_mut(layer) else { return false };
+        if l.visible == visible {
+            return false;
+        }
+        l.visible = visible;
+        let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
+        doc.config.layers = Arc::new(overrides);
+        doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
+        true
+    }
+
     pub fn close(&mut self, id: DocId) {
         self.docs.retain(|d| d.id != id);
     }
@@ -58,3 +359,6 @@ impl Session {
         &self.docs
     }
 }
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
new file mode 100644
index 000000000..fd7540387
--- /dev/null
+++ b/crates/engine/src/tests.rs
@@ -0,0 +1,191 @@
+use std::sync::Arc;
+
+use super::*;
+
+/// A minimal `n`-page document; page `i` shows "Page i+1".
+pub(crate) fn fixture(n: usize) -> Vec {
+    let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
+    let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
+    objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
+    objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
+    for i in 0..n {
+        objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
+        let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
+        objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
+    }
+    let mut out = b"%PDF-1.7\n".to_vec();
+    let mut offsets = Vec::new();
+    for (i, o) in objs.iter().enumerate() {
+        offsets.push(out.len());
+        out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+        out.extend_from_slice(o);
+        out.extend_from_slice(b"\nendobj\n");
+    }
+    let xref = out.len();
+    out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+    for o in offsets {
+        out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+    }
+    out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+    out
+}
+
+fn session_with(n: usize) -> (Session, DocId) {
+    let mut s = Session::new().with_clock(|| 1_700_000_000);
+    let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
+    (s, id)
+}
+
+fn page_texts(s: &Session, id: DocId) -> Vec {
+    let doc = s.get(id).unwrap();
+    let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
+    (0..doc.info.pages.len())
+        .map(|p| {
+            let r = r.render(RenderRequestFor::text(p));
+            r.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
+        })
+        .collect()
+}
+
+/// Shorthand for building text-extraction requests.
+struct RenderRequestFor;
+impl RenderRequestFor {
+    fn text(page: usize) -> printcraft_render::RenderRequest {
+        printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }
+    }
+}
+
+#[test]
+fn edits_update_view_data_and_mark_dirty() {
+    let (mut s, id) = session_with(3);
+    assert!(s.get(id).unwrap().editable());
+    assert!(!s.get(id).unwrap().dirty);
+    s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
+    let doc = s.get(id).unwrap();
+    assert!(doc.dirty);
+    assert_eq!(doc.info.pages.len(), 2, "inspection refreshed");
+    assert_eq!(doc.can_undo(), Some("Delete page"));
+    assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
+}
+
+#[test]
+fn undo_and_redo_restore_exact_states() {
+    let (mut s, id) = session_with(3);
+    let original = s.get(id).unwrap().bytes.clone();
+    s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap();
+    s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
+    assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
+    assert_eq!(s.undo(id).unwrap(), "Rotate page");
+    assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
+    assert_eq!(s.undo(id).unwrap(), "Move page");
+    assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes");
+    assert_eq!(s.undo(id), Err(EditError::NothingToUndo));
+    assert_eq!(s.redo(id).unwrap(), "Move page");
+    assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]);
+    // A new edit clears the redo stack.
+    s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap();
+    assert_eq!(s.redo(id), Err(EditError::NothingToRedo));
+    assert_eq!(s.get(id).unwrap().info.pages.len(), 4);
+}
+
+#[test]
+fn failed_edit_changes_nothing() {
+    let (mut s, id) = session_with(2);
+    let before = s.get(id).unwrap().bytes.clone();
+    let err = s.apply(id, Edit::DeletePages { pages: vec![0, 1] }).unwrap_err();
+    assert_eq!(err, EditError::Organize(printcraft_organize::OrganizeError::WouldRemoveAllPages));
+    let doc = s.get(id).unwrap();
+    assert!(!doc.dirty);
+    assert_eq!(doc.can_undo(), None);
+    assert_eq!(doc.bytes, before);
+}
+
+#[test]
+fn save_is_incremental_stamps_mod_date_and_rebases() {
+    let (mut s, id) = session_with(2);
+    let original = s.get(id).unwrap().bytes.clone();
+    s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap();
+    let saved = s.save_bytes(id).unwrap();
+    assert_eq!(&saved[..original.len()], &original[..], "incremental save keeps the original bytes");
+    let text = String::from_utf8_lossy(&saved[original.len()..]);
+    assert!(text.contains("/ModDate (D:20231114221320Z)"), "mod date stamped: {text}");
+    s.mark_saved(id, saved.clone(), Some("/tmp/out/report.pdf".into())).unwrap();
+    let doc = s.get(id).unwrap();
+    assert!(!doc.dirty);
+    assert_eq!(doc.name, "report.pdf");
+    assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report"));
+    assert_eq!(doc.info.title.as_deref(), Some("Quarterly report"));
+    // Saving again without edits writes the same bytes; a later edit appends just one revision.
+    assert_eq!(s.save_bytes(id).unwrap(), saved);
+    s.apply(id, Edit::RotatePages { pages: vec![1], degrees: -90 }).unwrap();
+    let second = s.save_bytes(id).unwrap();
+    assert_eq!(&second[..saved.len()], &saved[..]);
+    let reopened = printcraft_cos::Document::open(second).unwrap();
+    assert_eq!(reopened.revisions().len(), 3);
+}
+
+#[test]
+fn undo_still_works_after_save() {
+    let (mut s, id) = session_with(2);
+    s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
+    let saved = s.save_bytes(id).unwrap();
+    s.mark_saved(id, saved, None).unwrap();
+    s.undo(id).unwrap();
+    let doc = s.get(id).unwrap();
+    assert!(doc.dirty, "undoing past a save makes the document dirty again");
+    assert_eq!(doc.info.pages.len(), 2);
+}
+
+#[test]
+fn full_save_is_readable_and_smaller_after_deletes() {
+    let (mut s, id) = session_with(5);
+    s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap();
+    let full = s.save_full_bytes(id).unwrap();
+    assert!(full.len() < s.save_bytes(id).unwrap().len());
+    let mut s2 = Session::new();
+    let id2 = s2.open("full.pdf", None, full, None).unwrap();
+    assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]);
+}
+
+#[test]
+fn layer_choices_survive_edits() {
+    // No layers in the fixture: just make sure refresh keeps working with an empty list and
+    // that unknown layers are rejected.
+    let (mut s, id) = session_with(1);
+    assert!(!s.set_layer_visible(id, 0, false));
+    s.apply(id, Edit::InsertBlankPage { at: 1, width: 100.0, height: 100.0 }).unwrap();
+    assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
+}
+
+#[test]
+fn edits_to_unknown_documents_are_rejected() {
+    let (mut s, _) = session_with(1);
+    assert_eq!(s.apply(DocId(999), Edit::DeletePages { pages: vec![0] }), Err(EditError::NoDocument));
+    assert_eq!(s.undo(DocId(999)), Err(EditError::NoDocument));
+    assert!(s.save_bytes(DocId(999)).is_err());
+}
+
+#[test]
+fn batch_is_one_undo_step_and_all_or_nothing() {
+    let (mut s, id) = session_with(3);
+    let batch = Edit::Batch {
+        label: "Change properties".into(),
+        edits: vec![Edit::SetInfo { key: "Title".into(), value: "T".into() }, Edit::SetInfo { key: "Author".into(), value: "A".into() }],
+    };
+    s.apply(id, batch).unwrap();
+    assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A"));
+    assert_eq!(s.undo(id).unwrap(), "Change properties");
+    assert_eq!(s.get(id).unwrap().info_value("Title"), None);
+    assert_eq!(s.get(id).unwrap().can_undo(), None, "one step");
+    // A failing member rolls back the whole batch.
+    let bad = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, Edit::DeletePages { pages: vec![9] }] };
+    assert!(s.apply(id, bad).is_err());
+    assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
+}
+
+#[test]
+fn insert_blank_between_pages_keeps_both_neighbours() {
+    let (mut s, id) = session_with(2);
+    s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap();
+    assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]);
+}
diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml
new file mode 100644
index 000000000..f8ea7c198
--- /dev/null
+++ b/crates/filters/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "printcraft-filters"
+description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+flate2.workspace = true
+thiserror.workspace = true
+
+[dev-dependencies]
+proptest.workspace = true
+weezl.workspace = true
diff --git a/crates/filters/README.md b/crates/filters/README.md
new file mode 100644
index 000000000..a35749d41
--- /dev/null
+++ b/crates/filters/README.md
@@ -0,0 +1,50 @@
+# printcraft-filters
+
+PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
+
+- **Layer:** L0, standalone. No dependency on any other PrintCraft crate. `cos` depends on it (architecture §3 rule 3).
+- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters).
+- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
+
+## API
+
+```rust
+pub enum Filter { Flate, Lzw, AsciiHex, Ascii85, RunLength, Dct, Jpx, Jbig2, CcittFax, Crypt, Unknown(String) }
+Filter::from_name(b"FlateDecode" | b"Fl" | ...) -> Filter   // full names and inline-image abbreviations
+Filter::name(&self) -> &str; Filter::is_image_codec(&self) -> bool
+
+pub struct Params { predictor, colors, bits_per_component, columns, early_change }  // i64s; Default = 1, 1, 8, 1, 1
+pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) }
+
+decode(chain, data, max_output) -> Result, FilterError>
+decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError>
+encode(filter, params, data) -> Result, FilterError>
+encode_flate(data) -> Vec   // zlib, level 6
+```
+
+- `chain` is applied first to last, as in a `/Filter` array.
+- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
+- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`.
+- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
+
+## Behaviour
+
+| Filter | Decode | Encode |
+|---|---|---|
+| FlateDecode | zlib header checked, then inflated raw: a missing or wrong Adler-32 is tolerated; no valid header → raw deflate; trailing bytes ignored; preset dictionary → Corrupt; truncated → Corrupt (partial in tolerant mode) | zlib level 6 |
+| LZWDecode | 9–12-bit MSB-first codes, clear (256), EOD (257), `EarlyChange` 0/1; missing EOD/initial clear tolerated; a full table stops growing | greedy, leading clear, clear-table when the table fills, EOD |
+| Predictors (Flate/LZW) | 2 = TIFF (1/2/4/8/16 bpc), 10–15 = PNG with per-row tags 0–4; partial final rows decoded; bad params or tags → Corrupt | 2, 10 None, 11 Sub, 12 Up, 13 Average, 14 Paeth, 15 per-row optimum (min sum of signed residuals) |
+| ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` |
+| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
+| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
+
+## Tests
+
+- Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows).
+- LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings.
+- `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams).
+- `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`).
+
+## Status
+
+M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6.
diff --git a/crates/filters/fuzz/.gitignore b/crates/filters/fuzz/.gitignore
new file mode 100644
index 000000000..ab0eaa1a4
--- /dev/null
+++ b/crates/filters/fuzz/.gitignore
@@ -0,0 +1,5 @@
+target/
+corpus/
+artifacts/
+coverage/
+Cargo.lock
diff --git a/crates/filters/fuzz/Cargo.toml b/crates/filters/fuzz/Cargo.toml
new file mode 100644
index 000000000..efa0774d7
--- /dev/null
+++ b/crates/filters/fuzz/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "printcraft-filters-fuzz"
+version = "0.0.0"
+publish = false
+edition = "2024"
+license = "MIT OR Apache-2.0"
+
+[package.metadata]
+cargo-fuzz = true
+
+# Detached from the PrintCraft workspace: run with `cargo +nightly fuzz run decode`
+# from crates/filters/.
+[workspace]
+
+[dependencies]
+libfuzzer-sys = "0.4"
+printcraft-filters = { path = ".." }
+
+[[bin]]
+name = "decode"
+path = "fuzz_targets/decode.rs"
+test = false
+doc = false
+bench = false
diff --git a/crates/filters/fuzz/fuzz_targets/decode.rs b/crates/filters/fuzz/fuzz_targets/decode.rs
new file mode 100644
index 000000000..c9923ed73
--- /dev/null
+++ b/crates/filters/fuzz/fuzz_targets/decode.rs
@@ -0,0 +1,32 @@
+//! Feeds arbitrary bytes to every decoder, with parameters taken from the first bytes.
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use printcraft_filters::{Filter, Params, decode, decode_tolerant};
+
+const FILTERS: [Filter; 5] = [Filter::Flate, Filter::Lzw, Filter::AsciiHex, Filter::Ascii85, Filter::RunLength];
+const PREDICTORS: [i64; 8] = [1, 2, 10, 11, 12, 13, 14, 15];
+const BPC: [i64; 5] = [1, 2, 4, 8, 16];
+
+fuzz_target!(|data: &[u8]| {
+    let Some((head, body)) = data.split_first_chunk::<4>() else { return };
+    // Up to two chained filters; the second only when head[0] says so.
+    let params = Params {
+        predictor: PREDICTORS[usize::from(head[1] & 7)],
+        colors: i64::from(head[1] >> 3 & 3) + 1,
+        bits_per_component: BPC[usize::from(head[2]) % BPC.len()],
+        columns: i64::from(head[3]) + 1,
+        early_change: i64::from(head[2] >> 7),
+    };
+    let mut chain = vec![(FILTERS[usize::from(head[0] & 7) % FILTERS.len()].clone(), params.clone())];
+    if head[0] & 0x80 != 0 {
+        chain.insert(0, (FILTERS[usize::from(head[0] >> 3 & 7) % FILTERS.len()].clone(), Params::default()));
+    }
+    let max = 1 << 22;
+    if let Ok(out) = decode(&chain, body, max) {
+        assert!(out.len() <= max);
+    }
+    if let Ok((out, _)) = decode_tolerant(&chain, body, max) {
+        assert!(out.len() <= max);
+    }
+});
diff --git a/crates/filters/src/ascii85.rs b/crates/filters/src/ascii85.rs
new file mode 100644
index 000000000..ff3b39219
--- /dev/null
+++ b/crates/filters/src/ascii85.rs
@@ -0,0 +1,156 @@
+//! ASCII85Decode (ISO 32000-2 §7.4.3): groups of five characters `!`..`u` encode four
+//! bytes base-85, `z` stands for four zero bytes, `~>` is EOD, white space is ignored and
+//! a final partial group of n characters (2–5) yields n − 1 bytes. A leading `<~` and a
+//! missing EOD are tolerated.
+
+use crate::{Failure, Step, is_pdf_whitespace};
+
+const NAME: &str = "ASCII85Decode";
+
+fn push(out: &mut Vec, bytes: &[u8], max: usize) -> Result<(), Failure> {
+    if out.len() + bytes.len() > max {
+        return Err(Failure::limit(max));
+    }
+    out.extend_from_slice(bytes);
+    Ok(())
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+    let start = data.iter().position(|&b| !is_pdf_whitespace(b)).unwrap_or(data.len());
+    let body = data[start..].strip_prefix(b"<~").unwrap_or(&data[start..]);
+    let mut out = Vec::with_capacity((body.len() / 5 * 4 + 4).min(max));
+    let mut acc: u64 = 0;
+    let mut n = 0usize;
+    for (i, &b) in body.iter().enumerate() {
+        match b {
+            b'!'..=b'u' => {
+                acc = acc * 85 + u64::from(b - b'!');
+                n += 1;
+                if n == 5 {
+                    let Ok(v) = u32::try_from(acc) else {
+                        return Err(Failure::corrupt(NAME, format!("group ending at {i} exceeds 2^32"), out));
+                    };
+                    push(&mut out, &v.to_be_bytes(), max)?;
+                    acc = 0;
+                    n = 0;
+                }
+            }
+            b'z' if n == 0 => push(&mut out, &[0; 4], max)?,
+            b'z' => return Err(Failure::corrupt(NAME, format!("'z' inside a group at {i}"), out)),
+            // `~` starts the EOD marker; tolerate it without the following `>`.
+            b'~' => break,
+            b if is_pdf_whitespace(b) => {}
+            b => return Err(Failure::corrupt(NAME, format!("invalid character 0x{b:02x} at {i}"), out)),
+        }
+    }
+    match n {
+        0 => {}
+        1 => return Err(Failure::corrupt(NAME, "final group has a single character", out)),
+        _ => {
+            // Pad with `u` (84) to a full group and keep n − 1 bytes.
+            for _ in n..5 {
+                acc = acc * 85 + 84;
+            }
+            let Ok(v) = u32::try_from(acc) else {
+                return Err(Failure::corrupt(NAME, "final group exceeds 2^32", out));
+            };
+            push(&mut out, &v.to_be_bytes()[..n - 1], max)?;
+        }
+    }
+    Ok(out)
+}
+
+/// Encodes with `z` for zero groups, a newline every 75 characters, and a `~>` EOD.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+    let mut out = Vec::with_capacity(data.len() * 5 / 4 + data.len() / 60 + 8);
+    let mut col = 0;
+    let mut put = |out: &mut Vec, s: &[u8]| {
+        for &c in s {
+            if col == 75 {
+                out.push(b'\n');
+                col = 0;
+            }
+            out.push(c);
+            col += 1;
+        }
+    };
+    for chunk in data.chunks(4) {
+        let mut word = [0u8; 4];
+        word[..chunk.len()].copy_from_slice(chunk);
+        let mut v = u32::from_be_bytes(word);
+        if v == 0 && chunk.len() == 4 {
+            put(&mut out, b"z");
+            continue;
+        }
+        let mut digits = [0u8; 5];
+        for d in digits.iter_mut().rev() {
+            *d = (v % 85) as u8 + b'!';
+            v /= 85;
+        }
+        put(&mut out, &digits[..chunk.len() + 1]);
+    }
+    out.extend_from_slice(b"~>");
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::FilterError;
+
+    fn dec(d: &[u8]) -> Result, FilterError> {
+        decode(d, 1000).map_err(|f| f.error)
+    }
+
+    #[test]
+    fn vectors() {
+        // "Man " = 0x4D616E20 = 24·85⁴ + 73·85³ + 80·85² + 78·85 + 61 -> "9jqo^".
+        assert_eq!(dec(b"9jqo^~>").unwrap(), b"Man ");
+        assert_eq!(encode(b"Man "), b"9jqo^~>");
+        // "M" padded to 0x4D000000 -> "9`" (24, 63); decoding pads with 'u'.
+        assert_eq!(encode(b"M"), b"9`~>");
+        assert_eq!(dec(b"9`~>").unwrap(), b"M");
+        assert_eq!(dec(b"z~>").unwrap(), [0; 4]);
+        assert_eq!(encode(&[0; 4]), b"z~>");
+        // A partial zero group is not `z`.
+        assert_eq!(encode(&[0; 3]), b"!!!!~>");
+        assert_eq!(dec(b"!!!!~>").unwrap(), [0; 3]);
+        assert_eq!(dec(b"s8W-!~>").unwrap(), [0xff; 4]);
+    }
+
+    #[test]
+    fn syntax_tolerance() {
+        assert_eq!(dec(b"<~9jqo^~>").unwrap(), b"Man ");
+        assert_eq!(dec(b" \n<~9j\r\nq o\t^z~>").unwrap(), b"Man \0\0\0\0");
+        assert_eq!(dec(b"9jqo^").unwrap(), b"Man "); // missing EOD
+        assert_eq!(dec(b"9jqo^~").unwrap(), b"Man "); // bare ~
+        assert_eq!(dec(b"9jqo^~>garbage{").unwrap(), b"Man ");
+        assert_eq!(dec(b"~>").unwrap(), b"");
+        assert_eq!(dec(b"").unwrap(), b"");
+    }
+
+    #[test]
+    fn corrupt() {
+        assert!(matches!(dec(b"9jz~>"), Err(FilterError::Corrupt { .. })));
+        assert!(matches!(dec(b"9jqo^9~>"), Err(FilterError::Corrupt { .. })));
+        assert!(matches!(dec(b"uuuuu~>"), Err(FilterError::Corrupt { .. }))); // > 2^32 - 1
+        assert!(matches!(dec(b"9jqo^v~>"), Err(FilterError::Corrupt { .. })));
+        let f = decode(b"9jqo^{", 100).err().unwrap();
+        assert_eq!(f.partial, b"Man ");
+    }
+
+    #[test]
+    fn limit() {
+        assert!(matches!(decode(b"zz", 7).map_err(|f| f.error), Err(FilterError::LimitExceeded(7))));
+        assert!(matches!(decode(b"z9`", 4).map_err(|f| f.error), Err(FilterError::LimitExceeded(4))));
+        assert_eq!(decode(b"zz", 8).ok().unwrap().len(), 8);
+    }
+
+    #[test]
+    fn line_wrapping() {
+        let data: Vec = (0..=255u8).collect();
+        let e = encode(&data);
+        assert!(e.split(|&b| b == b'\n').all(|l| l.len() <= 77));
+        assert_eq!(dec(&e).unwrap_or_default().len(), 256);
+    }
+}
diff --git a/crates/filters/src/asciihex.rs b/crates/filters/src/asciihex.rs
new file mode 100644
index 000000000..5ba0574e8
--- /dev/null
+++ b/crates/filters/src/asciihex.rs
@@ -0,0 +1,106 @@
+//! ASCIIHexDecode (ISO 32000-2 §7.4.2): pairs of hex digits, white space ignored,
+//! `>` is EOD, an odd final digit is padded with 0. A missing EOD is tolerated.
+
+use crate::{Failure, Step, is_pdf_whitespace};
+
+const NAME: &str = "ASCIIHexDecode";
+
+fn nibble(b: u8) -> Option {
+    match b {
+        b'0'..=b'9' => Some(b - b'0'),
+        b'a'..=b'f' => Some(b - b'a' + 10),
+        b'A'..=b'F' => Some(b - b'A' + 10),
+        _ => None,
+    }
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+    let mut out = Vec::with_capacity((data.len() / 2).min(max));
+    let mut hi: Option = None;
+    for (i, &b) in data.iter().enumerate() {
+        if is_pdf_whitespace(b) {
+            continue;
+        }
+        if b == b'>' {
+            break;
+        }
+        let Some(n) = nibble(b) else {
+            return Err(Failure::corrupt(NAME, format!("invalid character 0x{b:02x} at offset {i}"), out));
+        };
+        match hi.take() {
+            None => hi = Some(n),
+            Some(h) => {
+                if out.len() == max {
+                    return Err(Failure::limit(max));
+                }
+                out.push(h << 4 | n);
+            }
+        }
+    }
+    if let Some(h) = hi {
+        if out.len() == max {
+            return Err(Failure::limit(max));
+        }
+        out.push(h << 4);
+    }
+    Ok(out)
+}
+
+/// Upper-case hex, a newline every 64 digits, terminated by `>`.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+    const HEX: &[u8; 16] = b"0123456789ABCDEF";
+    let mut out = Vec::with_capacity(data.len() * 2 + data.len() / 32 + 1);
+    for (i, &b) in data.iter().enumerate() {
+        if i > 0 && i % 32 == 0 {
+            out.push(b'\n');
+        }
+        out.push(HEX[usize::from(b >> 4)]);
+        out.push(HEX[usize::from(b & 15)]);
+    }
+    out.push(b'>');
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::FilterError;
+
+    fn dec(d: &[u8]) -> Result, FilterError> {
+        decode(d, 1000).map_err(|f| f.error)
+    }
+
+    #[test]
+    fn vectors() {
+        assert_eq!(dec(b"48656c6C6F>").unwrap(), b"Hello");
+        assert_eq!(dec(b" 4 8\n6\r5\t6\x0c c\x006c 6f >").unwrap(), b"Hello");
+        assert_eq!(dec(b"901FA>").unwrap(), [0x90, 0x1f, 0xa0]);
+        assert_eq!(dec(b"901FA").unwrap(), [0x90, 0x1f, 0xa0]); // missing EOD
+        assert_eq!(dec(b"41>zz garbage after EOD").unwrap(), b"A");
+        assert_eq!(dec(b">").unwrap(), b"");
+        assert_eq!(dec(b"").unwrap(), b"");
+    }
+
+    #[test]
+    fn invalid_char() {
+        let f = decode(b"4142G43>", 1000).err().unwrap();
+        assert!(matches!(f.error, FilterError::Corrupt { .. }));
+        assert_eq!(f.partial, b"AB");
+    }
+
+    #[test]
+    fn limit() {
+        assert!(matches!(decode(b"414243", 2).map_err(|f| f.error), Err(FilterError::LimitExceeded(2))));
+        assert!(matches!(decode(b"41424", 2).map_err(|f| f.error), Err(FilterError::LimitExceeded(2))));
+        assert_eq!(decode(b"4142", 2).ok().unwrap(), b"AB");
+    }
+
+    #[test]
+    fn encoding() {
+        assert_eq!(encode(b"Hello"), b"48656C6C6F>");
+        assert_eq!(encode(b""), b">");
+        let long = encode(&[0xab; 40]);
+        assert_eq!(long[64], b'\n');
+        assert_eq!(dec(&long).unwrap(), [0xab; 40]);
+    }
+}
diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs
new file mode 100644
index 000000000..7bb8f53fc
--- /dev/null
+++ b/crates/filters/src/flate.rs
@@ -0,0 +1,166 @@
+//! FlateDecode (ISO 32000-2 §7.4.4): zlib (RFC 1950) wrapping deflate (RFC 1951).
+//!
+//! Real-world quirks handled:
+//! - the Adler-32 trailer is not required (some producers omit or corrupt it), because the
+//!   deflate stream is inflated raw after the 2-byte zlib header;
+//! - streams without a valid zlib header are inflated as raw deflate;
+//! - data after the end of the deflate stream is ignored.
+
+use std::io::Write;
+
+use flate2::{Compression, Decompress, FlushDecompress, Status};
+
+use crate::{Failure, Step};
+
+const NAME: &str = "FlateDecode";
+
+/// Checks the RFC 1950 header: CM = 8, CINFO ≤ 7 and the FCHECK multiple-of-31 rule.
+fn zlib_header(data: &[u8]) -> Option<(u8, u8)> {
+    let (&cmf, &flg) = (data.first()?, data.get(1)?);
+    let ok = cmf & 0x0f == 8 && cmf >> 4 <= 7 && (u16::from(cmf) << 8 | u16::from(flg)) % 31 == 0;
+    ok.then_some((cmf, flg))
+}
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+    match zlib_header(data) {
+        Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
+        Some(_) => match inflate_raw(&data[2..], max) {
+            // A header that only looked valid: retry the whole input as raw deflate.
+            Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f),
+            r => r,
+        },
+        None => inflate_raw(data, max),
+    }
+}
+
+fn inflate_raw(input: &[u8], max: usize) -> Step {
+    let mut d = Decompress::new(false);
+    let mut out: Vec = Vec::new();
+    // One byte beyond the limit lets us tell "exactly max" from "more than max".
+    let cap = max.saturating_add(1);
+    loop {
+        if out.len() == out.capacity() {
+            if out.len() >= cap {
+                return Err(Failure::limit(max));
+            }
+            let grow = out.capacity().max(input.len().saturating_mul(2)).max(4096).min(cap - out.len());
+            out.reserve_exact(grow);
+        }
+        let consumed = d.total_in() as usize;
+        let produced = out.len();
+        let res = d.decompress_vec(&input[consumed.min(input.len())..], &mut out, FlushDecompress::None);
+        if out.len() > max {
+            return Err(Failure::limit(max));
+        }
+        match res {
+            Err(e) => return Err(Failure::corrupt(NAME, e.to_string(), out)),
+            Ok(Status::StreamEnd) => return Ok(out),
+            Ok(Status::Ok | Status::BufError) => {
+                let progressed = d.total_in() as usize != consumed || out.len() != produced;
+                let room_left = out.len() < out.capacity();
+                if room_left && (d.total_in() as usize >= input.len() || !progressed) {
+                    return Err(Failure::corrupt(NAME, "unexpected end of deflate data", out));
+                }
+            }
+        }
+    }
+}
+
+/// zlib at level 6.
+pub(crate) fn encode(data: &[u8]) -> Vec {
+    let mut e = flate2::write::ZlibEncoder::new(Vec::with_capacity(data.len() / 2 + 64), Compression::new(6));
+    // Writing into a Vec cannot fail.
+    e.write_all(data).expect("in-memory write");
+    e.finish().expect("in-memory write")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::FilterError;
+
+    fn dec(d: &[u8]) -> Result, FilterError> {
+        decode(d, 1 << 24).map_err(|f| f.error)
+    }
+
+    #[test]
+    fn known_zlib_vector() {
+        // zlib("a"): header 78 9C, fixed-Huffman block, Adler-32 0x00620062.
+        assert_eq!(dec(&[0x78, 0x9c, 0x4b, 0x04, 0x00, 0x00, 0x62, 0x00, 0x62]).unwrap(), b"a");
+    }
+
+    #[test]
+    fn hand_built_stored_block() {
+        // RFC 1951 §3.2.4 stored block: BFINAL=1 BTYPE=00, LEN=5, NLEN=!5, "hello",
+        // then Adler-32("hello") = 0x062C0215.
+        let v = [0x78, 0x01, 0x01, 0x05, 0x00, 0xfa, 0xff, b'h', b'e', b'l', b'l', b'o', 0x06, 0x2c, 0x02, 0x15];
+        assert_eq!(dec(&v).unwrap(), b"hello");
+        // Missing Adler-32 is tolerated.
+        assert_eq!(dec(&v[..12]).unwrap(), b"hello");
+        // Wrong Adler-32 is tolerated (as in other readers).
+        let mut bad = v;
+        bad[15] ^= 0xff;
+        assert_eq!(dec(&bad).unwrap(), b"hello");
+        // Trailing garbage after the stream is ignored.
+        let mut trailing = v.to_vec();
+        trailing.extend_from_slice(b"\r\nendstream");
+        assert_eq!(dec(&trailing).unwrap(), b"hello");
+    }
+
+    #[test]
+    fn raw_deflate_fallback() {
+        let data = b"raw deflate without any zlib header, repeated repeated repeated".repeat(4);
+        let mut e = flate2::write::DeflateEncoder::new(Vec::new(), Compression::new(6));
+        e.write_all(&data).unwrap();
+        let raw = e.finish().unwrap();
+        assert!(zlib_header(&raw).is_none());
+        assert_eq!(dec(&raw).unwrap(), data);
+    }
+
+    #[test]
+    fn round_trip_and_missing_checksum() {
+        let data: Vec = (0..50_000u32).map(|i| (i * i % 251) as u8).collect();
+        let z = encode(&data);
+        assert_eq!(&z[..2], &[0x78, 0x9c]);
+        assert_eq!(dec(&z).unwrap(), data);
+        assert_eq!(dec(&z[..z.len() - 4]).unwrap(), data);
+        assert_eq!(dec(&z[..z.len() - 2]).unwrap(), data);
+    }
+
+    #[test]
+    fn empty_input() {
+        assert_eq!(dec(&encode(b"")).unwrap(), b"");
+        assert!(matches!(dec(b""), Err(FilterError::Corrupt { .. })));
+    }
+
+    #[test]
+    fn corrupt_and_truncated() {
+        let data: Vec = (0..10_000u32).map(|i| (i % 13) as u8).collect();
+        let z = encode(&data);
+        match decode(&z[..z.len() / 2], 1 << 24) {
+            Err(f) => {
+                assert!(matches!(f.error, FilterError::Corrupt { .. }));
+                assert_eq!(&data[..f.partial.len()], &f.partial[..]);
+            }
+            Ok(_) => panic!("truncated stream decoded"),
+        }
+        // BTYPE = 11 is reserved (RFC 1951 §3.2.3).
+        assert!(matches!(dec(&[0x78, 0x9c, 0x07, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
+        // Preset dictionary.
+        assert!(matches!(dec(&[0x78, 0x20, 0, 0, 0, 0]), Err(FilterError::Corrupt { .. })));
+    }
+
+    #[test]
+    fn bomb_is_bounded() {
+        let z = encode(&vec![0u8; 16 << 20]);
+        assert!(z.len() < 32 * 1024);
+        assert!(matches!(dec_limit(&z, 1 << 20), Err(FilterError::LimitExceeded(n)) if n == 1 << 20));
+        assert_eq!(dec_limit(&z, 16 << 20).unwrap().len(), 16 << 20);
+        assert!(matches!(dec_limit(&z, (16 << 20) - 1), Err(FilterError::LimitExceeded(_))));
+        assert!(matches!(dec_limit(&z, 0), Err(FilterError::LimitExceeded(0))));
+    }
+
+    fn dec_limit(d: &[u8], max: usize) -> Result, FilterError> {
+        decode(d, max).map_err(|f| f.error)
+    }
+}
diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs
new file mode 100644
index 000000000..ce91500ee
--- /dev/null
+++ b/crates/filters/src/lib.rs
@@ -0,0 +1,325 @@
+//! PDF stream filters (ISO 32000-2 §7.4).
+//!
+//! Layer L0, standalone: this crate depends on no other PrintCraft crate.
+//!
+//! Decoding covers the general-purpose filters (`FlateDecode`, `LZWDecode` with
+//! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`).
+//! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`),
+//! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so
+//! callers keep such data encoded.
+//!
+//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
+//! defence) and never panics on malformed input.
+#![forbid(unsafe_code)]
+
+mod ascii85;
+mod asciihex;
+mod flate;
+mod lzw;
+mod predictor;
+mod runlength;
+
+use std::borrow::Cow;
+
+/// A PDF stream filter, identified by its `/Filter` name.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub enum Filter {
+    Flate,
+    Lzw,
+    AsciiHex,
+    Ascii85,
+    RunLength,
+    Dct,
+    Jpx,
+    Jbig2,
+    CcittFax,
+    Crypt,
+    /// Any other name (kept verbatim, lossily converted to UTF-8).
+    Unknown(String),
+}
+
+impl Filter {
+    /// Parses a filter name. Accepts the full names (ISO 32000-2 Table 6) and the
+    /// inline-image abbreviations (Table 92): `Fl`, `LZW`, `AHx`, `A85`, `RL`, `DCT`, `CCF`.
+    /// A leading `/` is ignored.
+    pub fn from_name(name: &[u8]) -> Filter {
+        let n = name.strip_prefix(b"/").unwrap_or(name);
+        match n {
+            b"FlateDecode" | b"Fl" => Filter::Flate,
+            b"LZWDecode" | b"LZW" => Filter::Lzw,
+            b"ASCIIHexDecode" | b"AHx" => Filter::AsciiHex,
+            b"ASCII85Decode" | b"A85" => Filter::Ascii85,
+            b"RunLengthDecode" | b"RL" => Filter::RunLength,
+            b"DCTDecode" | b"DCT" => Filter::Dct,
+            b"JPXDecode" => Filter::Jpx,
+            b"JBIG2Decode" => Filter::Jbig2,
+            b"CCITTFaxDecode" | b"CCF" => Filter::CcittFax,
+            b"Crypt" => Filter::Crypt,
+            other => Filter::Unknown(String::from_utf8_lossy(other).into_owned()),
+        }
+    }
+
+    /// The full PDF name (without the leading `/`).
+    pub fn name(&self) -> &str {
+        match self {
+            Filter::Flate => "FlateDecode",
+            Filter::Lzw => "LZWDecode",
+            Filter::AsciiHex => "ASCIIHexDecode",
+            Filter::Ascii85 => "ASCII85Decode",
+            Filter::RunLength => "RunLengthDecode",
+            Filter::Dct => "DCTDecode",
+            Filter::Jpx => "JPXDecode",
+            Filter::Jbig2 => "JBIG2Decode",
+            Filter::CcittFax => "CCITTFaxDecode",
+            Filter::Crypt => "Crypt",
+            Filter::Unknown(s) => s,
+        }
+    }
+
+    /// True for the image codecs (DCT, JPX, JBIG2, CCITT fax).
+    pub fn is_image_codec(&self) -> bool {
+        matches!(self, Filter::Dct | Filter::Jpx | Filter::Jbig2 | Filter::CcittFax)
+    }
+
+    fn is_decodable(&self) -> bool {
+        matches!(self, Filter::Flate | Filter::Lzw | Filter::AsciiHex | Filter::Ascii85 | Filter::RunLength)
+    }
+}
+
+/// `/DecodeParms` values relevant to the general-purpose filters (ISO 32000-2 Tables 8–9).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Params {
+    /// 1 = none, 2 = TIFF predictor 2, 10–15 = PNG predictors.
+    pub predictor: i64,
+    pub colors: i64,
+    pub bits_per_component: i64,
+    pub columns: i64,
+    /// LZW only: 1 (default) switches code width one code early, 0 does not.
+    pub early_change: i64,
+}
+
+impl Default for Params {
+    fn default() -> Self {
+        Params { predictor: 1, colors: 1, bits_per_component: 8, columns: 1, early_change: 1 }
+    }
+}
+
+/// Errors from decoding or encoding.
+#[derive(Debug, thiserror::Error)]
+pub enum FilterError {
+    #[error("{filter}: corrupt data: {detail}")]
+    Corrupt { filter: &'static str, detail: String },
+    #[error("decoded output exceeds the limit of {0} bytes")]
+    LimitExceeded(usize),
+    #[error("unsupported filter: {0}")]
+    Unsupported(String),
+}
+
+/// A decoder failure carrying whatever was decoded before the error.
+pub(crate) struct Failure {
+    pub(crate) error: FilterError,
+    pub(crate) partial: Vec,
+}
+
+impl Failure {
+    pub(crate) fn corrupt(filter: &'static str, detail: impl Into, partial: Vec) -> Self {
+        Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial }
+    }
+
+    pub(crate) fn limit(max: usize) -> Self {
+        Failure { error: FilterError::LimitExceeded(max), partial: Vec::new() }
+    }
+}
+
+pub(crate) type Step = Result, Failure>;
+
+/// Decodes `data` through `chain` (first element applied first).
+///
+/// Image codecs, `Crypt` and unknown filters anywhere in the chain give
+/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds
+/// every intermediate buffer.
+pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> {
+    run(chain, data, max_output, true).map(|(v, _)| v)
+}
+
+/// Like [`decode`], but on corrupt data returns what was decoded before the error and
+/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
+/// chain still run on the partial data. Limit and unsupported errors are still errors.
+pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> {
+    run(chain, data, max_output, false)
+}
+
+fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> {
+    if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) {
+        return Err(FilterError::Unsupported(f.name().to_owned()));
+    }
+    let mut cur: Cow<[u8]> = Cow::Borrowed(data);
+    let mut partial = false;
+    for (filter, params) in chain {
+        cur = Cow::Owned(match decode_one(filter, params, &cur, max) {
+            Ok(v) => v,
+            Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => {
+                partial = true;
+                out
+            }
+            Err(f) => return Err(f.error),
+        });
+    }
+    Ok((cur.into_owned(), partial))
+}
+
+fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step {
+    match filter {
+        Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"),
+        Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"),
+        Filter::AsciiHex => asciihex::decode(data, max),
+        Filter::Ascii85 => ascii85::decode(data, max),
+        Filter::RunLength => runlength::decode(data, max),
+        other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }),
+    }
+}
+
+/// Applies the predictor to a decoder's result, including to partial output on failure.
+fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
+    match step {
+        Ok(v) => predictor::decode(params, v, name),
+        Err(Failure { error, partial }) => {
+            let partial = match predictor::decode(params, partial, name) {
+                Ok(v) => v,
+                Err(f) => f.partial,
+            };
+            Err(Failure { error, partial })
+        }
+    }
+}
+
+/// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex,
+/// ASCII85, RunLength. For Flate and LZW, `params.predictor >= 2` applies the
+/// predictor first (TIFF 2, PNG 10–15; 15 chooses the per-row optimum).
+pub fn encode(filter: &Filter, params: &Params, data: &[u8]) -> Result, FilterError> {
+    match filter {
+        Filter::Flate => Ok(flate::encode(&predictor::encode(params, data)?)),
+        Filter::Lzw => Ok(lzw::encode(&predictor::encode(params, data)?, params.early_change != 0)),
+        Filter::AsciiHex => Ok(asciihex::encode(data)),
+        Filter::Ascii85 => Ok(ascii85::encode(data)),
+        Filter::RunLength => Ok(runlength::encode(data)),
+        other => Err(FilterError::Unsupported(format!("encoding with {}", other.name()))),
+    }
+}
+
+/// zlib (RFC 1950) at compression level 6.
+pub fn encode_flate(data: &[u8]) -> Vec {
+    flate::encode(data)
+}
+
+/// PDF white-space characters (ISO 32000-2 Table 1).
+pub(crate) fn is_pdf_whitespace(b: u8) -> bool {
+    matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn names_and_abbreviations() {
+        for (full, abbr, f) in [
+            (&b"FlateDecode"[..], &b"Fl"[..], Filter::Flate),
+            (b"LZWDecode", b"LZW", Filter::Lzw),
+            (b"ASCIIHexDecode", b"AHx", Filter::AsciiHex),
+            (b"ASCII85Decode", b"A85", Filter::Ascii85),
+            (b"RunLengthDecode", b"RL", Filter::RunLength),
+            (b"DCTDecode", b"DCT", Filter::Dct),
+            (b"CCITTFaxDecode", b"CCF", Filter::CcittFax),
+        ] {
+            assert_eq!(Filter::from_name(full), f);
+            assert_eq!(Filter::from_name(abbr), f);
+            assert_eq!(f.name().as_bytes(), full);
+        }
+        assert_eq!(Filter::from_name(b"/JPXDecode"), Filter::Jpx);
+        assert_eq!(Filter::from_name(b"JBIG2Decode"), Filter::Jbig2);
+        assert_eq!(Filter::from_name(b"Crypt"), Filter::Crypt);
+        assert_eq!(Filter::from_name(b"Foo"), Filter::Unknown("Foo".into()));
+        assert_eq!(Filter::Unknown("Foo".into()).name(), "Foo");
+        assert!(Filter::Dct.is_image_codec() && Filter::CcittFax.is_image_codec());
+        assert!(!Filter::Flate.is_image_codec() && !Filter::Crypt.is_image_codec());
+    }
+
+    #[test]
+    fn default_params() {
+        let p = Params::default();
+        assert_eq!((p.predictor, p.colors, p.bits_per_component, p.columns, p.early_change), (1, 1, 8, 1, 1));
+    }
+
+    #[test]
+    fn unsupported_filters() {
+        for f in [Filter::Dct, Filter::Jpx, Filter::Jbig2, Filter::CcittFax, Filter::Crypt, Filter::Unknown("X".into())] {
+            let chain = [(Filter::AsciiHex, Params::default()), (f.clone(), Params::default())];
+            assert!(matches!(decode(&chain, b"00>", 100), Err(FilterError::Unsupported(_))));
+            assert!(matches!(decode_tolerant(&chain, b"00>", 100), Err(FilterError::Unsupported(_))));
+            assert!(matches!(encode(&f, &Params::default(), b"x"), Err(FilterError::Unsupported(_))));
+        }
+    }
+
+    #[test]
+    fn empty_chain_is_identity() {
+        assert_eq!(decode(&[], b"abc", 1).unwrap(), b"abc");
+    }
+
+    #[test]
+    fn chain_ascii85_flate() {
+        let data = b"Hello, chained filters! Hello, chained filters!".repeat(10);
+        let z = encode_flate(&data);
+        let a = encode(&Filter::Ascii85, &Params::default(), &z).unwrap();
+        let chain = [(Filter::Ascii85, Params::default()), (Filter::Flate, Params::default())];
+        assert_eq!(decode(&chain, &a, 1 << 20).unwrap(), data);
+    }
+
+    #[test]
+    fn chain_three_filters_with_predictor() {
+        let p = Params { predictor: 12, colors: 3, columns: 5, ..Params::default() };
+        let data: Vec = (0..300u32).map(|i| (i * 7 % 251) as u8).collect();
+        let lzw = encode(&Filter::Lzw, &p, &data).unwrap();
+        let rl = encode(&Filter::RunLength, &Params::default(), &lzw).unwrap();
+        let hex = encode(&Filter::AsciiHex, &Params::default(), &rl).unwrap();
+        let chain = [(Filter::AsciiHex, Params::default()), (Filter::RunLength, Params::default()), (Filter::Lzw, p)];
+        assert_eq!(decode(&chain, &hex, 1 << 20).unwrap(), data);
+    }
+
+    #[test]
+    fn limit_applies_to_intermediate_buffers() {
+        let hex = encode(&Filter::AsciiHex, &Params::default(), &[7u8; 100]).unwrap();
+        let chain = [(Filter::AsciiHex, Params::default())];
+        assert!(matches!(decode(&chain, &hex, 99), Err(FilterError::LimitExceeded(99))));
+        assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100);
+    }
+
+    #[test]
+    fn tolerant_flate_returns_partial() {
+        let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
+        let mut z = encode_flate(&data);
+        let cut = z.len() / 2;
+        z.truncate(cut);
+        let chain = [(Filter::Flate, Params::default())];
+        assert!(matches!(decode(&chain, &z, 1 << 20), Err(FilterError::Corrupt { .. })));
+        let (out, partial) = decode_tolerant(&chain, &z, 1 << 20).unwrap();
+        assert!(partial);
+        assert!(!out.is_empty() && out.len() < data.len());
+        assert_eq!(&data[..out.len()], &out[..]);
+        // Complete data is not partial.
+        let (out, partial) = decode_tolerant(&chain, &encode_flate(&data), 1 << 20).unwrap();
+        assert!(!partial);
+        assert_eq!(out, data);
+    }
+
+    #[test]
+    fn tolerant_partial_flows_through_predictor() {
+        let p = Params { predictor: 12, columns: 4, ..Params::default() };
+        let data: Vec = (0..4000u32).map(|i| (i * 31 % 256) as u8).collect();
+        let mut z = encode(&Filter::Flate, &p, &data).unwrap();
+        z.truncate(z.len() - 20);
+        let (out, partial) = decode_tolerant(&[(Filter::Flate, p)], &z, 1 << 20).unwrap();
+        assert!(partial);
+        assert_eq!(&data[..out.len()], &out[..]);
+    }
+}
diff --git a/crates/filters/src/lzw.rs b/crates/filters/src/lzw.rs
new file mode 100644
index 000000000..9653ac2fb
--- /dev/null
+++ b/crates/filters/src/lzw.rs
@@ -0,0 +1,314 @@
+//! LZWDecode (ISO 32000-2 §7.4.4.2): variable-width codes of 9–12 bits, MSB first,
+//! 256 = clear-table, 257 = EOD, first free code 258. With `EarlyChange` 1 (the
+//! default) the code width grows one code early.
+//!
+//! Tolerated: data ending without EOD, a missing initial clear-table code, a table that
+//! fills up without a clear-table code (entries are then no longer added).
+
+use std::collections::HashMap;
+
+use crate::{Failure, Step};
+
+const NAME: &str = "LZWDecode";
+const CLEAR: u16 = 256;
+const EOD: u16 = 257;
+const FIRST: usize = 258;
+const TABLE: usize = 4096;
+/// The encoder emits clear-table when its next free code reaches this value.
+const ENCODER_RESET_AT: usize = TABLE;
+
+/// The code width a decoder uses when its next free code is `next`.
+fn width(next: usize, early: bool) -> u32 {
+    match next + usize::from(early) {
+        0..512 => 9,
+        512..1024 => 10,
+        1024..2048 => 11,
+        _ => 12,
+    }
+}
+
+struct BitReader<'a> {
+    data: &'a [u8],
+    pos: usize,
+    acc: u32,
+    bits: u32,
+}
+
+impl BitReader<'_> {
+    fn read(&mut self, n: u32) -> Option {
+        while self.bits < n {
+            let &b = self.data.get(self.pos)?;
+            self.pos += 1;
+            self.acc = (self.acc << 8) | u32::from(b);
+            self.bits += 8;
+        }
+        self.bits -= n;
+        let v = (self.acc >> self.bits) & ((1 << n) - 1);
+        self.acc &= (1 << self.bits) - 1;
+        Some(v as u16)
+    }
+}
+
+pub(crate) fn decode(data: &[u8], early: bool, max: usize) -> Step {
+    // Table entry = (prefix code, last byte, first byte, length).
+    let mut prefix = vec![0u16; TABLE];
+    let mut last = vec![0u8; TABLE];
+    let mut first = vec![0u8; TABLE];
+    let mut len = vec![0u16; TABLE];
+    for i in 0..256 {
+        last[i] = i as u8;
+        first[i] = i as u8;
+        len[i] = 1;
+    }
+    let mut next = FIRST;
+    let mut w = 9;
+    let mut prev: Option = None;
+    let mut r = BitReader { data, pos: 0, acc: 0, bits: 0 };
+    let mut out = Vec::with_capacity(data.len().saturating_mul(2).min(max));
+
+    while let Some(code) = r.read(w) {
+        match code {
+            CLEAR => {
+                next = FIRST;
+                w = 9;
+                prev = None;
+                continue;
+            }
+            EOD => break,
+            _ => {}
+        }
+        let code = usize::from(code);
+        if let Some(p) = prev {
+            let head = if code < next {
+                first[code]
+            } else if code == next && next < TABLE {
+                first[p]
+            } else {
+                return Err(Failure::corrupt(NAME, format!("code {code} not in table (next {next})"), out));
+            };
+            if next < TABLE {
+                prefix[next] = p as u16;
+                last[next] = head;
+                first[next] = first[p];
+                len[next] = len[p] + 1;
+                next += 1;
+            }
+        } else if code >= 256 {
+            return Err(Failure::corrupt(NAME, format!("code {code} before any table entry"), out));
+        }
+        // Emit the string for `code`, walking the prefix chain backwards.
+        let n = usize::from(len[code]);
+        if out.len() + n > max {
+            return Err(Failure::limit(max));
+        }
+        let start = out.len();
+        out.resize(start + n, 0);
+        let mut c = code;
+        for slot in out[start..].iter_mut().rev() {
+            *slot = last[c];
+            c = usize::from(prefix[c]);
+        }
+        prev = Some(code);
+        w = width(next, early);
+    }
+    Ok(out)
+}
+
+struct BitWriter {
+    out: Vec,
+    acc: u32,
+    bits: u32,
+}
+
+impl BitWriter {
+    fn write(&mut self, code: u16, n: u32) {
+        self.acc = (self.acc << n) | u32::from(code);
+        self.bits += n;
+        while self.bits >= 8 {
+            self.bits -= 8;
+            self.out.push((self.acc >> self.bits) as u8);
+        }
+        self.acc &= (1 << self.bits) - 1;
+    }
+
+    fn finish(mut self) -> Vec {
+        if self.bits > 0 {
+            self.out.push((self.acc << (8 - self.bits)) as u8);
+        }
+        self.out
+    }
+}
+
+/// Greedy LZW with a leading clear-table code and a trailing EOD.
+pub(crate) fn encode(data: &[u8], early: bool) -> Vec {
+    let mut wr = BitWriter { out: Vec::with_capacity(data.len() / 2 + 8), acc: 0, bits: 0 };
+    let mut dict: HashMap<(u16, u8), u16> = HashMap::new();
+    // `next` is the encoder's next free code; `dnext` mirrors the decoder's, which lags
+    // one entry behind (the decoder adds an entry only once it sees the following code).
+    let mut next = FIRST;
+    let mut dnext = FIRST;
+    let mut codes_since_clear = 0usize;
+    wr.write(CLEAR, 9);
+    let emit = |wr: &mut BitWriter, code: u16, dnext: &mut usize, since: &mut usize| {
+        wr.write(code, width(*dnext, early));
+        if *since > 0 && *dnext < TABLE {
+            *dnext += 1;
+        }
+        *since += 1;
+    };
+    let Some((&b0, rest)) = data.split_first() else {
+        wr.write(EOD, 9);
+        return wr.finish();
+    };
+    let mut cur = u16::from(b0);
+    for &b in rest {
+        if let Some(&c) = dict.get(&(cur, b)) {
+            cur = c;
+            continue;
+        }
+        emit(&mut wr, cur, &mut dnext, &mut codes_since_clear);
+        dict.insert((cur, b), next as u16);
+        next += 1;
+        if next == ENCODER_RESET_AT {
+            wr.write(CLEAR, width(dnext, early));
+            dict.clear();
+            next = FIRST;
+            dnext = FIRST;
+            codes_since_clear = 0;
+        }
+        cur = u16::from(b);
+    }
+    emit(&mut wr, cur, &mut dnext, &mut codes_since_clear);
+    wr.write(EOD, width(dnext, early));
+    wr.finish()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::FilterError;
+
+    fn dec(d: &[u8], early: bool) -> Result, FilterError> {
+        decode(d, early, 1 << 26).map_err(|f| f.error)
+    }
+
+    /// ISO 32000-2 §7.4.4.2 example: codes 256 45 258 258 65 259 66 257.
+    const SPEC_IN: [u8; 10] = [45, 45, 45, 45, 45, 65, 45, 45, 45, 66];
+    const SPEC_OUT: [u8; 9] = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01];
+
+    #[test]
+    fn spec_example() {
+        assert_eq!(dec(&SPEC_OUT, true).unwrap(), SPEC_IN);
+        assert_eq!(encode(&SPEC_IN, true), SPEC_OUT);
+        // Below 511 codes the early-change setting makes no difference.
+        assert_eq!(dec(&SPEC_OUT, false).unwrap(), SPEC_IN);
+    }
+
+    #[test]
+    fn missing_eod_and_clear() {
+        // Drop the EOD (last 9 bits span the final 2 bytes; keep bits for 66 only).
+        let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+        for c in [45u16, 258, 258, 65, 259, 66] {
+            w.write(c, 9);
+        }
+        assert_eq!(dec(&w.finish(), true).unwrap(), SPEC_IN);
+    }
+
+    #[test]
+    fn invalid_codes() {
+        let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+        for c in [256u16, 65, 300] {
+            w.write(c, 9);
+        }
+        let f = decode(&w.finish(), true, 1000).err().unwrap();
+        assert!(matches!(f.error, FilterError::Corrupt { .. }));
+        assert_eq!(f.partial, b"A");
+        let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+        w.write(258, 9);
+        assert!(matches!(dec(&w.finish(), true), Err(FilterError::Corrupt { .. })));
+    }
+
+    #[test]
+    fn widths() {
+        assert_eq!((width(510, true), width(511, true), width(511, false), width(512, false)), (9, 10, 9, 10));
+        assert_eq!((width(1022, true), width(1023, true), width(2047, true), width(4095, false)), (10, 11, 12, 12));
+    }
+
+    fn noisy(n: usize, seed: u32) -> Vec {
+        let mut s = seed;
+        (0..n)
+            .map(|_| {
+                s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345);
+                ((s >> 16) % 7) as u8 * 31
+            })
+            .collect()
+    }
+
+    #[test]
+    fn all_widths_and_clears_round_trip() {
+        for early in [false, true] {
+            for data in [noisy(200_000, 1), (0..=255u8).cycle().take(70_000).collect(), vec![9u8; 100_000]] {
+                let e = encode(&data, early);
+                assert_eq!(dec(&e, early).unwrap(), data);
+            }
+        }
+    }
+
+    #[test]
+    fn cross_check_with_weezl() {
+        use weezl::BitOrder::Msb;
+        for data in [noisy(150_000, 7), SPEC_IN.to_vec(), vec![]] {
+            // early change 1 is the TIFF size switch; 0 is the plain variant.
+            let tiff = weezl::encode::Encoder::with_tiff_size_switch(Msb, 8).encode(&data).unwrap();
+            assert_eq!(dec(&tiff, true).unwrap(), data);
+            let plain = weezl::encode::Encoder::new(Msb, 8).encode(&data).unwrap();
+            assert_eq!(dec(&plain, false).unwrap(), data);
+            let ours = encode(&data, true);
+            assert_eq!(weezl::decode::Decoder::with_tiff_size_switch(Msb, 8).decode(&ours).unwrap(), data);
+            let ours = encode(&data, false);
+            assert_eq!(weezl::decode::Decoder::new(Msb, 8).decode(&ours).unwrap(), data);
+        }
+    }
+
+    #[test]
+    fn full_table_without_clear() {
+        // Hand-roll a stream that fills the table and keeps going at 12 bits.
+        let data = noisy(40_000, 3);
+        let mut w = BitWriter { out: vec![], acc: 0, bits: 0 };
+        let mut dict: HashMap, u16> = (0..=255u8).map(|b| (vec![b], u16::from(b))).collect();
+        let (mut next, mut dnext, mut since) = (FIRST, FIRST, 0usize);
+        let mut cur: Vec = vec![];
+        let mut put = |w: &mut BitWriter, code: u16, dnext: &mut usize| {
+            w.write(code, width(*dnext, true));
+            if since > 0 && *dnext < TABLE {
+                *dnext += 1;
+            }
+            since += 1;
+        };
+        w.write(CLEAR, 9);
+        for &b in &data {
+            let mut ext = cur.clone();
+            ext.push(b);
+            if dict.contains_key(&ext) {
+                cur = ext;
+                continue;
+            }
+            put(&mut w, dict[&cur], &mut dnext);
+            if next < TABLE {
+                dict.insert(ext, next as u16);
+                next += 1;
+            }
+            cur = vec![b];
+        }
+        put(&mut w, dict[&cur], &mut dnext);
+        w.write(EOD, width(dnext, true));
+        assert_eq!(dec(&w.finish(), true).unwrap(), data);
+    }
+
+    #[test]
+    fn limit() {
+        let e = encode(&vec![0u8; 100_000], true);
+        assert!(matches!(decode(&e, true, 50_000).map_err(|f| f.error), Err(FilterError::LimitExceeded(50_000))));
+        assert_eq!(decode(&e, true, 100_000).ok().unwrap().len(), 100_000);
+    }
+}
diff --git a/crates/filters/src/predictor.rs b/crates/filters/src/predictor.rs
new file mode 100644
index 000000000..f9348941b
--- /dev/null
+++ b/crates/filters/src/predictor.rs
@@ -0,0 +1,332 @@
+//! Predictor functions for FlateDecode and LZWDecode (ISO 32000-2 §7.4.4.4).
+//!
+//! - Predictor 2: TIFF 6.0 horizontal differencing (Section 14), per component, for
+//!   1, 2, 4, 8 and 16 bits per component.
+//! - Predictors 10–15: PNG filters (RFC 2083 §6): each row carries its own filter-type
+//!   byte (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth), whatever the predictor value is.
+//!
+//! A final partial row is decoded as far as its data goes.
+
+use crate::{Failure, FilterError, Params, Step};
+
+struct Layout {
+    bpc: usize,
+    colors: usize,
+    /// Bytes per complete pixel, at least 1 (the PNG `bpp`).
+    bpp: usize,
+    /// Bytes per row (without the PNG tag byte).
+    row: usize,
+    /// Samples per row (colors × columns).
+    samples: usize,
+}
+
+fn layout(p: &Params) -> Result {
+    let bpc = match p.bits_per_component {
+        b @ (1 | 2 | 4 | 8 | 16) => b as usize,
+        b => return Err(format!("invalid BitsPerComponent {b}")),
+    };
+    if !(1..=255).contains(&p.colors) {
+        return Err(format!("invalid Colors {}", p.colors));
+    }
+    if p.columns < 1 {
+        return Err(format!("invalid Columns {}", p.columns));
+    }
+    let colors = p.colors as usize;
+    let samples = usize::try_from(p.columns).ok().and_then(|c| c.checked_mul(colors)).ok_or_else(|| format!("Columns {} too large", p.columns))?;
+    let bits = samples.checked_mul(bpc).ok_or_else(|| format!("Columns {} too large", p.columns))?;
+    Ok(Layout { bpc, colors, bpp: (colors * bpc).div_ceil(8), row: bits.div_ceil(8), samples })
+}
+
+/// Undoes the predictor named by `p.predictor` (1 = identity).
+pub(crate) fn decode(p: &Params, data: Vec, name: &'static str) -> Step {
+    match p.predictor {
+        1 => Ok(data),
+        2 | 10..=15 => {
+            let l = layout(p).map_err(|e| Failure::corrupt(name, e, Vec::new()))?;
+            if p.predictor == 2 { Ok(tiff_decode(&l, data)) } else { png_decode(&l, &data, name) }
+        }
+        other => Err(Failure::corrupt(name, format!("invalid Predictor {other}"), Vec::new())),
+    }
+}
+
+/// Applies the predictor named by `p.predictor` (1 = identity; 15 = per-row optimum).
+pub(crate) fn encode(p: &Params, data: &[u8]) -> Result, FilterError> {
+    match p.predictor {
+        1 => Ok(data.to_vec()),
+        2 | 10..=15 => {
+            let l = layout(p).map_err(FilterError::Unsupported)?;
+            Ok(if p.predictor == 2 { tiff_encode(&l, data) } else { png_encode(&l, data, p.predictor) })
+        }
+        other => Err(FilterError::Unsupported(format!("Predictor {other}"))),
+    }
+}
+
+// ---- TIFF predictor 2 ----
+
+fn get(row: &[u8], i: usize, bpc: usize) -> u16 {
+    match bpc {
+        16 => u16::from_be_bytes([row[2 * i], row[2 * i + 1]]),
+        8 => u16::from(row[i]),
+        _ => {
+            let bit = i * bpc;
+            let shift = 8 - bpc - bit % 8;
+            u16::from(row[bit / 8] >> shift) & ((1 << bpc) - 1)
+        }
+    }
+}
+
+fn set(row: &mut [u8], i: usize, bpc: usize, v: u16) {
+    match bpc {
+        16 => row[2 * i..2 * i + 2].copy_from_slice(&v.to_be_bytes()),
+        8 => row[i] = v as u8,
+        _ => {
+            let bit = i * bpc;
+            let shift = 8 - bpc - bit % 8;
+            let mask = (((1u16 << bpc) - 1) << shift) as u8;
+            row[bit / 8] = (row[bit / 8] & !mask) | (((v << shift) as u8) & mask);
+        }
+    }
+}
+
+/// Complete samples available in a (possibly partial) row.
+fn samples_in(l: &Layout, row_len: usize) -> usize {
+    (row_len * 8 / l.bpc).min(l.samples)
+}
+
+fn tiff_decode(l: &Layout, mut data: Vec) -> Vec {
+    let mask = if l.bpc == 16 { u16::MAX } else { (1u16 << l.bpc) - 1 };
+    for row in data.chunks_mut(l.row) {
+        for i in l.colors..samples_in(l, row.len()) {
+            let v = get(row, i, l.bpc).wrapping_add(get(row, i - l.colors, l.bpc)) & mask;
+            set(row, i, l.bpc, v);
+        }
+    }
+    data
+}
+
+fn tiff_encode(l: &Layout, data: &[u8]) -> Vec {
+    let mask = if l.bpc == 16 { u16::MAX } else { (1u16 << l.bpc) - 1 };
+    let mut out = data.to_vec();
+    for row in out.chunks_mut(l.row) {
+        // Descending, so the left neighbour still holds its original value.
+        for i in (l.colors..samples_in(l, row.len())).rev() {
+            let v = get(row, i, l.bpc).wrapping_sub(get(row, i - l.colors, l.bpc)) & mask;
+            set(row, i, l.bpc, v);
+        }
+    }
+    out
+}
+
+// ---- PNG predictors ----
+
+fn paeth(a: u8, b: u8, c: u8) -> u8 {
+    let p = i16::from(a) + i16::from(b) - i16::from(c);
+    let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
+    if pa <= pb && pa <= pc {
+        a
+    } else if pb <= pc {
+        b
+    } else {
+        c
+    }
+}
+
+/// The PNG predictor value for byte `i` given the reconstructed current row `cur`
+/// (bytes before `i`) and the prior row `up`.
+fn predict(tag: u8, cur: &[u8], up: &[u8], i: usize, bpp: usize) -> u8 {
+    let a = if i >= bpp { cur[i - bpp] } else { 0 };
+    let b = up[i];
+    let c = if i >= bpp { up[i - bpp] } else { 0 };
+    match tag {
+        1 => a,
+        2 => b,
+        3 => ((u16::from(a) + u16::from(b)) / 2) as u8,
+        4 => paeth(a, b, c),
+        _ => 0,
+    }
+}
+
+fn png_decode(l: &Layout, data: &[u8], name: &'static str) -> Step {
+    let mut out = Vec::with_capacity(data.len());
+    // The prior row: never longer than the input, even for absurd Columns values.
+    let mut up = vec![0u8; l.row.min(data.len())];
+    for chunk in data.chunks(l.row.saturating_add(1)) {
+        let (tag, src) = (chunk[0], &chunk[1..]);
+        if tag > 4 {
+            return Err(Failure::corrupt(name, format!("invalid PNG filter type {tag}"), out));
+        }
+        let start = out.len();
+        for (i, &x) in src.iter().enumerate() {
+            let pred = predict(tag, &out[start..], &up, i, l.bpp);
+            out.push(x.wrapping_add(pred));
+        }
+        up[..src.len()].copy_from_slice(&out[start..]);
+    }
+    Ok(out)
+}
+
+fn png_filter(tag: u8, cur: &[u8], up: &[u8], bpp: usize, dst: &mut Vec) {
+    dst.clear();
+    dst.push(tag);
+    dst.extend(cur.iter().enumerate().map(|(i, &x)| x.wrapping_sub(predict(tag, cur, up, i, bpp))));
+}
+
+/// Sum of residuals as signed bytes: the usual heuristic for choosing a PNG filter.
+fn cost(filtered: &[u8]) -> u64 {
+    filtered[1..].iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum()
+}
+
+fn png_encode(l: &Layout, data: &[u8], predictor: i64) -> Vec {
+    let rows = data.len().div_ceil(l.row.max(1));
+    let mut out = Vec::with_capacity(data.len() + rows);
+    let zeros = vec![0u8; l.row.min(data.len())];
+    let (mut best, mut trial) = (Vec::new(), Vec::new());
+    let mut up: &[u8] = &zeros;
+    for cur in data.chunks(l.row) {
+        let up_row = &up[..cur.len()];
+        if predictor == 15 {
+            png_filter(0, cur, up_row, l.bpp, &mut best);
+            let mut best_cost = cost(&best);
+            for tag in 1..=4 {
+                png_filter(tag, cur, up_row, l.bpp, &mut trial);
+                let c = cost(&trial);
+                if c < best_cost {
+                    best_cost = c;
+                    std::mem::swap(&mut best, &mut trial);
+                }
+            }
+        } else {
+            png_filter((predictor - 10) as u8, cur, up_row, l.bpp, &mut best);
+        }
+        out.extend_from_slice(&best);
+        up = cur;
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn p(predictor: i64, colors: i64, bpc: i64, columns: i64) -> Params {
+        Params { predictor, colors, bits_per_component: bpc, columns, early_change: 1 }
+    }
+
+    fn dec(params: &Params, data: &[u8]) -> Result, FilterError> {
+        decode(params, data.to_vec(), "FlateDecode").map_err(|f| f.error)
+    }
+
+    #[test]
+    fn png_hand_vectors() {
+        // 3 columns, 1 color, 8 bpc. Rows: None, Sub, Up, Average, Paeth.
+        let enc = [
+            0, 10, 20, 30, //  None  -> 10 20 30
+            1, 5, 1, 1, //     Sub   -> 5 6 7
+            2, 1, 1, 1, //     Up    -> 6 7 8
+            3, 10, 10, 10, //  Avg   -> 10+3=13, 10+(13+7)/2=20, 10+(20+8)/2=24
+            4, 1, 1,
+            1, //     Paeth: a=0,b=13,c=0 -> p=13 -> b: 14; a=14,b=20,c=13: p=21 -> pa=7,pb=1,pc=8 -> b: 21;
+               //                        a=21,b=24,c=20: p=25 -> pa=4,pb=1,pc=5 -> b: 25
+        ];
+        let out = dec(&p(15, 1, 8, 3), &enc).unwrap();
+        assert_eq!(out, [10, 20, 30, 5, 6, 7, 6, 7, 8, 13, 20, 24, 14, 21, 25]);
+    }
+
+    #[test]
+    fn paeth_tie_breaks() {
+        // RFC 2083 order: a, then b, then c.
+        assert_eq!(paeth(1, 1, 1), 1);
+        assert_eq!(paeth(10, 20, 15), 15); // p=15: pa=5 pb=5 pc=0 -> c
+        assert_eq!(paeth(20, 10, 10), 20); // p=20: pa=0 -> a
+        assert_eq!(paeth(5, 9, 7), 7); //    p=7: pa=2 pb=2 pc=0 -> c
+    }
+
+    #[test]
+    fn png_multibyte_pixels() {
+        // colors=3 bpc=16 -> bpp=6; 1 column; Sub on the first pixel uses a=0 throughout.
+        let enc = [1, 1, 2, 3, 4, 5, 6, 2, 1, 1, 1, 1, 1, 1];
+        let out = dec(&p(11, 3, 16, 1), &enc).unwrap();
+        assert_eq!(out, [1, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 7]);
+        // colors=3 bpc=8, 2 columns: Sub looks 3 bytes back.
+        let enc = [1, 1, 2, 3, 1, 1, 1];
+        assert_eq!(dec(&p(11, 3, 8, 2), &enc).unwrap(), [1, 2, 3, 2, 3, 4]);
+    }
+
+    #[test]
+    fn png_partial_final_row() {
+        let enc = [0, 1, 2, 3, 4, 2, 1, 1];
+        assert_eq!(dec(&p(12, 1, 8, 4), &enc).unwrap(), [1, 2, 3, 4, 2, 3]);
+        // A lone tag byte at the end yields nothing.
+        assert_eq!(dec(&p(12, 1, 8, 4), &[0, 1, 2, 3, 4, 2]).unwrap(), [1, 2, 3, 4]);
+    }
+
+    #[test]
+    fn png_invalid_tag() {
+        let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode");
+        let f = r.err().unwrap();
+        assert!(matches!(f.error, FilterError::Corrupt { .. }));
+        assert_eq!(f.partial, [1, 2]);
+    }
+
+    #[test]
+    fn tiff_8bit() {
+        // 2 colors, 3 columns: differences per component.
+        let enc = [10, 20, 1, 2, 3, 4, /* row 2 */ 5, 5, 250, 10, 1, 1];
+        assert_eq!(dec(&p(2, 2, 8, 3), &enc).unwrap(), [10, 20, 11, 22, 14, 26, 5, 5, 255, 15, 0, 16]);
+    }
+
+    #[test]
+    fn tiff_16bit() {
+        let enc = [0x01, 0x00, 0xff, 0xff, 0x00, 0x02];
+        assert_eq!(dec(&p(2, 1, 16, 3), &enc).unwrap(), [0x01, 0x00, 0x00, 0xff, 0x01, 0x01]);
+    }
+
+    #[test]
+    fn tiff_sub_byte() {
+        // 1 bpc, 1 color, 10 columns: bits 1 0 0 1 1 0 0 0 | 1 1 (pad 000000)
+        // Cumulative XOR: 1 1 1 0 1 1 1 1 | 0 1
+        let enc = [0b1001_1000, 0b1100_0000];
+        assert_eq!(dec(&p(2, 1, 1, 10), &enc).unwrap(), [0b1110_1111, 0b0100_0000]);
+        // 4 bpc, 1 color, 3 columns: samples 5, 15, 3 -> 5, 4, 7 (mod 16).
+        assert_eq!(dec(&p(2, 1, 4, 3), &[0x5f, 0x30]).unwrap(), [0x54, 0x70]);
+        // 2 bpc, 2 colors, 2 columns: samples 1 2 3 3 -> 1 2 0 1.
+        assert_eq!(dec(&p(2, 2, 2, 2), &[0b0110_1111]).unwrap(), [0b0110_0001]);
+        // Padding bits are left alone.
+        assert_eq!(dec(&p(2, 1, 4, 1), &[0x5f]).unwrap(), [0x5f]);
+    }
+
+    #[test]
+    fn tiff_partial_rows() {
+        // 16-bit with an odd trailing byte: the lone byte is left as is.
+        assert_eq!(dec(&p(2, 1, 16, 4), &[0, 1, 0, 1, 9]).unwrap(), [0, 1, 0, 2, 9]);
+        assert_eq!(dec(&p(2, 1, 8, 4), &[1, 1, 1, 1, 2, 2]).unwrap(), [1, 2, 3, 4, 2, 4]);
+    }
+
+    #[test]
+    fn invalid_params() {
+        for bad in [p(2, 1, 3, 1), p(12, 0, 8, 1), p(12, 1, 8, 0), p(3, 1, 8, 1), p(12, 1, 8, i64::MAX), p(2, 300, 8, 1)] {
+            assert!(matches!(dec(&bad, &[0, 1, 2]), Err(FilterError::Corrupt { .. })), "{bad:?}");
+            assert!(encode(&bad, &[1, 2]).is_err());
+        }
+        // Huge but representable Columns does not allocate a huge row.
+        assert_eq!(dec(&p(12, 1, 8, 1 << 40), &[2, 7, 8]).unwrap(), [7, 8]);
+    }
+
+    #[test]
+    fn encode_choices() {
+        let data = [1u8, 2, 3, 4, 5, 6];
+        let up = encode(&p(12, 1, 8, 3), &data).unwrap();
+        assert_eq!(up, [2, 1, 2, 3, 2, 3, 3, 3]);
+        let paeth = encode(&p(14, 1, 8, 3), &data).unwrap();
+        assert_eq!(paeth[0], 4);
+        assert_eq!(paeth[4], 4);
+        // Per-row optimum picks Sub for a ramp.
+        let ramp: Vec = (0..64).collect();
+        let opt = encode(&p(15, 1, 8, 64), &ramp).unwrap();
+        assert_eq!(opt[0], 1);
+        for pred in [10, 11, 12, 13, 14, 15] {
+            let e = encode(&p(pred, 1, 8, 3), &data).unwrap();
+            assert_eq!(dec(&p(pred, 1, 8, 3), &e).unwrap(), data);
+        }
+    }
+}
diff --git a/crates/filters/src/runlength.rs b/crates/filters/src/runlength.rs
new file mode 100644
index 000000000..e77b619f9
--- /dev/null
+++ b/crates/filters/src/runlength.rs
@@ -0,0 +1,117 @@
+//! RunLengthDecode (ISO 32000-2 §7.4.5): a length byte L of 0–127 copies the next
+//! L + 1 bytes, 129–255 repeats the next byte 257 − L times, 128 is EOD.
+//! A missing EOD is tolerated; a truncated run is corrupt.
+
+use crate::{Failure, Step};
+
+const NAME: &str = "RunLengthDecode";
+
+pub(crate) fn decode(data: &[u8], max: usize) -> Step {
+    let mut out = Vec::with_capacity(data.len().saturating_mul(2).min(max));
+    let mut i = 0;
+    while let Some(&l) = data.get(i) {
+        i += 1;
+        match l {
+            128 => break,
+            0..=127 => {
+                let n = usize::from(l) + 1;
+                let avail = n.min(data.len() - i);
+                if out.len() + avail > max {
+                    return Err(Failure::limit(max));
+                }
+                out.extend_from_slice(&data[i..i + avail]);
+                i += avail;
+                if avail < n {
+                    return Err(Failure::corrupt(NAME, "truncated literal run", out));
+                }
+            }
+            _ => {
+                let Some(&b) = data.get(i) else {
+                    return Err(Failure::corrupt(NAME, "truncated repeat run", out));
+                };
+                i += 1;
+                let n = 257 - usize::from(l);
+                if out.len() + n > max {
+                    return Err(Failure::limit(max));
+                }
+                out.resize(out.len() + n, b);
+            }
+        }
+    }
+    Ok(out)
+}
+
+pub(crate) fn encode(data: &[u8]) -> Vec {
+    let mut out = Vec::with_capacity(data.len() + data.len() / 128 + 2);
+    let n = data.len();
+    let mut i = 0;
+    while i < n {
+        let mut run = 1;
+        while i + run < n && run < 128 && data[i + run] == data[i] {
+            run += 1;
+        }
+        if run >= 2 {
+            out.push((257 - run) as u8);
+            out.push(data[i]);
+            i += run;
+            continue;
+        }
+        // Literal: extend until a repeat begins or 128 bytes.
+        let start = i;
+        let mut j = i + 1;
+        while j < n && j - start < 128 && !(j + 1 < n && data[j] == data[j + 1]) {
+            j += 1;
+        }
+        out.push((j - start - 1) as u8);
+        out.extend_from_slice(&data[start..j]);
+        i = j;
+    }
+    out.push(128);
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::FilterError;
+
+    fn dec(d: &[u8]) -> Result, FilterError> {
+        decode(d, 10_000).map_err(|f| f.error)
+    }
+
+    #[test]
+    fn vectors() {
+        assert_eq!(dec(&[2, b'a', b'b', b'c', 254, b'x', 128]).unwrap(), b"abcxxx");
+        assert_eq!(dec(&[129, 7, 128]).unwrap(), [7; 128]);
+        assert!(dec(&[127]).is_err());
+        assert_eq!(dec(&[0, b'q']).unwrap(), b"q"); // missing EOD
+        assert_eq!(dec(&[128, 0, b'q']).unwrap(), b""); // data after EOD ignored
+        assert_eq!(dec(&[]).unwrap(), b"");
+    }
+
+    #[test]
+    fn truncated() {
+        let f = decode(&[4, b'a', b'b'], 100).err().unwrap();
+        assert!(matches!(f.error, FilterError::Corrupt { .. }));
+        assert_eq!(f.partial, b"ab");
+        assert!(matches!(dec(&[0, b'a', 200]), Err(FilterError::Corrupt { .. })));
+    }
+
+    #[test]
+    fn limit() {
+        assert!(matches!(decode(&[129, 0, 129, 0], 200).map_err(|f| f.error), Err(FilterError::LimitExceeded(200))));
+        assert!(matches!(decode(&[3, 1, 2, 3, 4], 3).map_err(|f| f.error), Err(FilterError::LimitExceeded(3))));
+        assert_eq!(decode(&[129, 0, 129, 0], 256).ok().unwrap().len(), 256);
+    }
+
+    #[test]
+    fn encoding() {
+        assert_eq!(encode(b"abcxxx"), [2, b'a', b'b', b'c', 254, b'x', 128]);
+        assert_eq!(encode(b""), [128]);
+        assert_eq!(encode(&[5; 300]), [129, 5, 129, 5, 213, 5, 128]);
+        let lit: Vec = (0..200u8).collect();
+        let e = encode(&lit);
+        assert_eq!((e[0], e[129]), (127, 71));
+        assert_eq!(dec(&e).unwrap(), lit);
+    }
+}
diff --git a/crates/filters/tests/proptests.proptest-regressions b/crates/filters/tests/proptests.proptest-regressions
new file mode 100644
index 000000000..0b9bbac5c
--- /dev/null
+++ b/crates/filters/tests/proptests.proptest-regressions
@@ -0,0 +1,7 @@
+# Seeds for failure cases proptest has generated in the past. It is
+# automatically read and these particular cases re-run before any
+# novel cases are generated.
+#
+# It is recommended to check this file in to source control so that
+# everyone who runs the test benefits from these saved cases.
+cc 690714485bf1c84d6f7788b0205b5d62942b9774699a364161224381ebb08f22 # shrinks to filter = Flate, params = Params { predictor: 10, colors: 1, bits_per_component: 1, columns: 1, early_change: 0 }, data = [74, 4, 40, 11, 72, 5, 123, 154, 191, 76, 212, 154, 73, 3, 113, 92, 51, 190, 160, 141, 12, 106, 83, 206, 91, 214, 202, 23, 39, 151, 117, 114, 220, 76, 204, 235, 186, 176, 80, 229, 63, 79, 67, 63, 177, 94, 142, 223, 140, 248, 1, 203, 157, 88, 175, 77, 69, 155, 159, 152, 47, 216, 81, 234, 169, 144, 235, 202, 75, 191, 233, 128, 225, 162, 96, 126, 245, 57, 135, 167, 184, 53, 138, 215, 153, 248, 165, 18, 81, 128, 44, 139, 223, 76, 191, 58, 137, 48, 93, 14, 80, 58, 30, 63, 40, 235, 68, 159, 137, 113, 123, 24, 150, 84, 78, 190, 207, 36, 189, 220, 192, 161, 121, 179, 1, 242, 152, 205, 64, 67, 50, 245, 94, 185, 81, 132, 27, 25, 122, 7, 223, 178, 147, 70, 27, 130, 226, 217, 54, 87, 112, 14, 196, 133, 193, 27, 147, 30, 38, 182, 108, 72, 75, 107, 113, 244, 65, 9, 62, 11, 66, 234, 146, 180, 56, 85, 170, 255, 38, 37, 159, 48, 3, 159, 125, 230, 34, 110, 7, 58, 92, 66, 14, 29, 50, 98, 56, 228, 118, 243, 32, 99, 158, 252, 246, 249, 185, 106, 11, 127, 218, 104, 216, 204, 23, 59, 85, 34, 123, 28, 253, 14, 147, 201, 205, 176, 60, 39, 249, 143, 205, 190, 183, 83, 27, 213, 23, 225, 172, 43, 225, 31, 62, 205, 53, 88, 202, 200, 75, 152, 252, 105, 20, 95, 20, 0, 229, 170, 23, 222, 252, 208, 41, 206, 133, 167, 41, 89, 208, 136, 98, 203, 93, 112, 18, 75, 54, 217, 191, 163, 165, 88, 137, 203, 7, 66, 218, 184, 251, 48, 25, 144, 29, 183, 119, 101, 192, 234, 116, 81, 211, 255, 22, 16, 20, 36, 190, 253, 73, 246, 9, 141, 99, 90, 178, 252, 93, 112, 233, 213, 42, 127, 227, 208, 125, 129, 41, 99, 111, 228, 123, 106, 131, 39, 204, 158, 18, 6, 221, 168, 135, 60, 107, 156, 254, 93, 255, 163, 34, 142, 110, 16, 207, 84, 243, 180, 145, 42, 240, 252, 64, 53, 225, 211, 231, 255, 215, 221, 181, 69, 8, 59, 134, 254, 98, 16, 157, 100, 115, 145, 190, 115, 49, 153, 255, 242, 159, 248, 37, 192, 48, 133, 42, 158, 35, 111, 7, 101, 1, 12, 131, 117, 149, 23, 224, 80, 101, 146, 235, 24, 214, 166, 254, 39, 121, 65, 109, 7, 115, 4, 126, 238, 207, 122, 72, 78, 170, 80, 239, 13, 233, 101, 110, 101, 121, 219, 159, 194, 78, 33, 198, 239, 149, 64, 6, 105, 138, 106, 34, 82, 253, 66, 74, 219, 101, 247, 133, 23, 140, 254, 221, 164, 77, 66, 28, 202, 253, 230, 152, 200, 164, 143, 126, 92, 231, 72, 120, 249, 230, 119, 24, 109, 185, 137, 26, 206, 91, 90, 76, 140, 134, 137, 67, 83, 52, 105, 135, 184, 180, 231, 213, 190, 252, 144, 129, 85, 96, 4, 62, 70, 197, 73, 164, 61, 209, 150, 227, 138, 92, 25, 117, 84, 161, 81, 247, 94, 53, 207, 93, 59, 223, 222, 87, 255, 216, 219, 143, 36, 255, 131, 117, 9, 30, 144, 196, 209, 139, 253, 187, 193, 126, 243, 21, 66, 19, 226, 95, 166, 245, 11, 63, 220, 30, 55, 255, 172, 108, 87, 187, 2, 3, 242, 243, 175, 134, 88, 128, 97, 68, 102, 241, 94, 109, 219, 219, 6, 38, 155, 249, 194, 33, 175, 48, 141, 41, 82, 186, 85, 232, 157, 160, 145, 233, 103, 190, 239, 106, 247, 241, 11, 216, 90, 56, 154, 18, 119, 33, 181, 109, 145, 80, 158, 51, 129, 150, 147, 255, 14, 135, 86, 62, 250, 84, 8, 235, 237, 246, 227, 137, 112, 175, 239, 226, 182, 92, 188, 236, 254, 87, 49, 18, 208, 139, 48, 200, 151, 201, 196, 182, 182], max = 1319
diff --git a/crates/filters/tests/proptests.rs b/crates/filters/tests/proptests.rs
new file mode 100644
index 000000000..a4300e68a
--- /dev/null
+++ b/crates/filters/tests/proptests.rs
@@ -0,0 +1,150 @@
+//! Round-trip property tests (encode → decode) for every encodable filter and predictor,
+//! and fuzz-style tests feeding arbitrary bytes and parameters to every decoder.
+
+use printcraft_filters::{Filter, FilterError, Params, decode, decode_tolerant, encode};
+use proptest::prelude::*;
+
+const MAX: usize = 1 << 24;
+
+fn encodable() -> impl Strategy {
+    prop_oneof![Just(Filter::Flate), Just(Filter::Lzw), Just(Filter::AsciiHex), Just(Filter::Ascii85), Just(Filter::RunLength),]
+}
+
+fn any_filter() -> impl Strategy {
+    prop_oneof![
+        encodable(),
+        Just(Filter::Dct),
+        Just(Filter::Jpx),
+        Just(Filter::Jbig2),
+        Just(Filter::CcittFax),
+        Just(Filter::Crypt),
+        Just(Filter::Unknown("Foo".into())),
+    ]
+}
+
+fn valid_params() -> impl Strategy {
+    (prop::sample::select(vec![1i64, 2, 10, 11, 12, 13, 14, 15]), 1i64..=5, prop::sample::select(vec![1i64, 2, 4, 8, 16]), 1i64..=40, 0i64..=1)
+        .prop_map(|(predictor, colors, bits_per_component, columns, early_change)| Params {
+            predictor,
+            colors,
+            bits_per_component,
+            columns,
+            early_change,
+        })
+}
+
+/// Anything, including nonsense values.
+fn wild_params() -> impl Strategy {
+    let wild = prop_oneof![Just(i64::MIN), Just(i64::MAX), Just(-1i64), Just(0i64), 0i64..=40, any::()];
+    (
+        prop_oneof![valid_params().prop_map(|p| p.predictor), wild.clone()],
+        prop_oneof![1i64..=5, wild.clone()],
+        prop_oneof![Just(8i64), Just(16), Just(1), wild.clone()],
+        prop_oneof![1i64..=40, wild.clone()],
+        wild,
+    )
+        .prop_map(|(predictor, colors, bits_per_component, columns, early_change)| Params {
+            predictor,
+            colors,
+            bits_per_component,
+            columns,
+            early_change,
+        })
+}
+
+/// Low-entropy data (so LZW/Flate/RunLength exercise their repeat paths) mixed with noise.
+fn data() -> impl Strategy> {
+    prop_oneof![prop::collection::vec(any::(), 0..2000), prop::collection::vec(prop::sample::select(vec![0u8, 1, 7, 255]), 0..6000),]
+}
+
+proptest! {
+    #[test]
+    fn round_trip(filter in encodable(), params in valid_params(), data in data()) {
+        let enc = encode(&filter, ¶ms, &data).unwrap();
+        let chain = [(filter, params)];
+        prop_assert_eq!(&decode(&chain, &enc, MAX).unwrap(), &data);
+        let (out, partial) = decode_tolerant(&chain, &enc, MAX).unwrap();
+        prop_assert!(!partial);
+        prop_assert_eq!(&out, &data);
+    }
+
+    #[test]
+    fn round_trip_chain(a in encodable(), b in encodable(), params in valid_params(), data in data()) {
+        let inner = encode(&b, ¶ms, &data).unwrap();
+        let outer = encode(&a, &Params::default(), &inner).unwrap();
+        let chain = [(a, Params::default()), (b, params)];
+        prop_assert_eq!(decode(&chain, &outer, MAX).unwrap(), data);
+    }
+
+    #[test]
+    fn lzw_long_round_trip(early in 0i64..=1, data in prop::collection::vec(0u8..4, 20_000..60_000)) {
+        let p = Params { early_change: early, ..Params::default() };
+        let enc = encode(&Filter::Lzw, &p, &data).unwrap();
+        prop_assert_eq!(decode(&[(Filter::Lzw, p)], &enc, MAX).unwrap(), data);
+    }
+
+    #[test]
+    fn output_limit_is_respected(filter in encodable(), params in valid_params(), data in data(), max in 0usize..3000) {
+        let enc = encode(&filter, ¶ms, &data).unwrap();
+        // PNG predictors add a tag byte per row to the intermediate (pre-predictor) buffer.
+        let png = matches!(filter, Filter::Flate | Filter::Lzw) && params.predictor >= 10;
+        let row = (params.colors * params.bits_per_component * params.columns + 7) as usize / 8;
+        let intermediate = if png { data.len() + data.len().div_ceil(row) } else { data.len() };
+        match decode(&[(filter, params)], &enc, max) {
+            Ok(out) => prop_assert!(out.len() <= max && out == data),
+            Err(FilterError::LimitExceeded(m)) => prop_assert!(m == max && intermediate > max),
+            Err(e) => prop_assert!(false, "unexpected {e}"),
+        }
+    }
+
+    #[test]
+    fn decoders_never_panic(
+        chain in prop::collection::vec((any_filter(), wild_params()), 0..4),
+        data in prop::collection::vec(any::(), 0..3000),
+        max in prop_oneof![Just(0usize), 1usize..100_000],
+    ) {
+        if let Ok(out) = decode(&chain, &data, max) {
+            prop_assert!(out.len() <= max || chain.is_empty());
+        }
+        if let Ok((out, _)) = decode_tolerant(&chain, &data, max) {
+            prop_assert!(out.len() <= max || chain.is_empty());
+        }
+    }
+
+    #[test]
+    fn single_decoders_never_panic(filter in encodable(), params in wild_params(), data in prop::collection::vec(any::(), 0..3000)) {
+        let chain = [(filter, params)];
+        let _ = decode(&chain, &data, 1 << 20);
+        let _ = decode_tolerant(&chain, &data, 1 << 20);
+    }
+
+    #[test]
+    fn encoders_never_panic(filter in any_filter(), params in wild_params(), data in prop::collection::vec(any::(), 0..2000)) {
+        if let Ok(enc) = encode(&filter, ¶ms, &data) {
+            prop_assert_eq!(decode(&[(filter, params)], &enc, MAX).unwrap(), data);
+        }
+    }
+
+    #[test]
+    fn mutated_streams_never_panic(filter in encodable(), params in valid_params(), data in data(), flips in prop::collection::vec((any::(), any::()), 1..8)) {
+        let mut enc = encode(&filter, ¶ms, &data).unwrap();
+        for (i, x) in flips {
+            if !enc.is_empty() {
+                let k = i.index(enc.len());
+                enc[k] ^= x;
+            }
+        }
+        let chain = [(filter, params)];
+        let _ = decode(&chain, &enc, 1 << 20);
+        let _ = decode_tolerant(&chain, &enc, 1 << 20);
+    }
+}
+
+#[test]
+fn flate_bomb_in_chain() {
+    let bomb = printcraft_filters::encode_flate(&vec![0u8; 32 << 20]);
+    let a85 = encode(&Filter::Ascii85, &Params::default(), &bomb).unwrap();
+    let chain = [(Filter::Ascii85, Params::default()), (Filter::Flate, Params::default())];
+    assert!(matches!(decode(&chain, &a85, 1 << 20), Err(FilterError::LimitExceeded(_))));
+    assert!(matches!(decode_tolerant(&chain, &a85, 1 << 20), Err(FilterError::LimitExceeded(_))));
+}
diff --git a/crates/organize/Cargo.toml b/crates/organize/Cargo.toml
new file mode 100644
index 000000000..46fb417e9
--- /dev/null
+++ b/crates/organize/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "printcraft-organize"
+description = "Page and document-structure edits: rotate, delete, move, insert pages; document info"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+printcraft-cos = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+hayro-syntax = { workspace = true }
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
new file mode 100644
index 000000000..e59a8af81
--- /dev/null
+++ b/crates/organize/src/lib.rs
@@ -0,0 +1,214 @@
+//! printcraft-organize — page and document-structure edits (L4).
+//!
+//! Every operation mutates a `printcraft_cos::Document` (copy-on-write), so callers snapshot the
+//! document before an edit for undo. Operations never drop data they do not understand.
+//!
+//! Page-tree strategy: before restructuring, inheritable page attributes (`Resources`,
+//! `MediaBox`, `CropBox`, `Rotate` — ISO 32000-2 §7.7.3.4) are copied onto each page, then the
+//! tree is rebuilt as a single flat `/Pages` node. Intermediate nodes become unreachable (a full
+//! save drops them; an incremental save leaves them untouched). Flat trees are valid for any
+//! page count and are what most producers write for small documents.
+
+use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum OrganizeError {
+    #[error("the document has no page tree")]
+    NoPageTree,
+    #[error("page {0} does not exist")]
+    NoSuchPage(usize),
+    #[error("a document must keep at least one page")]
+    WouldRemoveAllPages,
+    #[error("{0}")]
+    Cos(#[from] printcraft_cos::CosError),
+}
+
+const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
+
+/// A leaf page in document order.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PageRef {
+    pub obj: ObjRef,
+}
+
+fn pages_root(doc: &Document) -> Result {
+    let root = doc.root().ok_or(OrganizeError::NoPageTree)?;
+    let catalog = doc.get(root);
+    catalog.as_dict().and_then(|d| d.reference(b"Pages")).ok_or(OrganizeError::NoPageTree)
+}
+
+/// All leaf pages in order, with their effective inherited attributes.
+fn walk(doc: &Document) -> Result, OrganizeError> {
+    let root = pages_root(doc)?;
+    let mut out = Vec::new();
+    let mut seen = std::collections::HashSet::new();
+    // (node, inherited attributes from ancestors)
+    let mut stack: Vec<(ObjRef, Dict)> = vec![(root, Dict::new())];
+    while let Some((node, inherited)) = stack.pop() {
+        if !seen.insert(node) || seen.len() > 1_000_000 {
+            continue; // cycles in broken trees
+        }
+        let obj = doc.get(node);
+        let Some(d) = obj.as_dict() else { continue };
+        let mut attrs = inherited.clone();
+        for k in INHERITABLE {
+            if let Some(v) = d.get(k) {
+                attrs.set(k.to_vec(), v.clone());
+            }
+        }
+        let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
+        if is_pages {
+            let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
+            for k in kids.iter().rev() {
+                if let Some(r) = k.as_ref() {
+                    stack.push((r, attrs.clone()));
+                }
+            }
+        } else {
+            out.push((node, attrs));
+        }
+    }
+    Ok(out)
+}
+
+/// Leaf pages in document order.
+pub fn pages(doc: &Document) -> Result, OrganizeError> {
+    Ok(walk(doc)?.into_iter().map(|(obj, _)| PageRef { obj }).collect())
+}
+
+pub fn page_count(doc: &Document) -> Result {
+    Ok(walk(doc)?.len())
+}
+
+/// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down).
+fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> {
+    let root = pages_root(doc)?;
+    for (page, inherited) in order {
+        doc.update_dict(*page, |d| {
+            for k in INHERITABLE {
+                if !d.contains(k)
+                    && let Some(v) = inherited.get(k)
+                {
+                    d.set(k.to_vec(), v.clone());
+                }
+            }
+            d.set(b"Parent".to_vec(), Object::Ref(root));
+        })?;
+    }
+    doc.update_dict(root, |d| {
+        d.set(b"Type".to_vec(), Object::name("Pages"));
+        d.set(b"Kids".to_vec(), Object::Array(order.iter().map(|(r, _)| Object::Ref(*r)).collect()));
+        d.set(b"Count".to_vec(), Object::Int(order.len() as i64));
+        // Attributes now live on the pages; leaving them here would be harmless but misleading.
+        for k in INHERITABLE {
+            d.remove(k);
+        }
+        d.remove(b"Parent");
+    })?;
+    Ok(())
+}
+
+fn check(indices: &[usize], n: usize) -> Result<(), OrganizeError> {
+    match indices.iter().find(|i| **i >= n) {
+        Some(i) => Err(OrganizeError::NoSuchPage(*i)),
+        None => Ok(()),
+    }
+}
+
+/// Rotate pages by a multiple of 90° (positive = clockwise), adjusting `/Rotate` (§7.7.3.3).
+pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Result<(), OrganizeError> {
+    let all = walk(doc)?;
+    check(indices, all.len())?;
+    let delta = (degrees / 90) * 90;
+    for &i in indices {
+        let (page, inherited) = &all[i];
+        let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0);
+        let new = (current + delta).rem_euclid(360);
+        doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?;
+    }
+    Ok(())
+}
+
+/// Delete pages. Refuses to delete every page.
+pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+    let all = walk(doc)?;
+    check(indices, all.len())?;
+    let keep: Vec<(ObjRef, Dict)> = all.iter().enumerate().filter(|(i, _)| !indices.contains(i)).map(|(_, p)| p.clone()).collect();
+    if keep.is_empty() {
+        return Err(OrganizeError::WouldRemoveAllPages);
+    }
+    rebuild(doc, &keep)
+}
+
+/// Move the pages at `indices` (kept in their relative order) so they start at position `to`
+/// in the resulting document.
+pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<(), OrganizeError> {
+    let all = walk(doc)?;
+    check(indices, all.len())?;
+    let mut sorted = indices.to_vec();
+    sorted.sort_unstable();
+    sorted.dedup();
+    let moving: Vec<(ObjRef, Dict)> = sorted.iter().map(|i| all[*i].clone()).collect();
+    let mut rest: Vec<(ObjRef, Dict)> = all.iter().enumerate().filter(|(i, _)| !sorted.contains(i)).map(|(_, p)| p.clone()).collect();
+    let at = to.min(rest.len());
+    rest.splice(at..at, moving);
+    rebuild(doc, &rest)
+}
+
+/// Insert a blank page of `width × height` points at position `at` (0 = before the first page).
+pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) -> Result {
+    let mut all = walk(doc)?;
+    let root = pages_root(doc)?;
+    let mut page = Dict::new();
+    page.set(b"Type".to_vec(), Object::name("Page"));
+    page.set(b"Parent".to_vec(), Object::Ref(root));
+    page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(width), Object::Real(height)]));
+    page.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
+    let r = doc.add(page);
+    let at = at.min(all.len());
+    all.insert(at, (r, Dict::new()));
+    rebuild(doc, &all)?;
+    Ok(r)
+}
+
+/// Document information keys editable in Document Properties ▸ Description.
+pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
+
+/// Read a document-information entry as text.
+pub fn info(doc: &Document, key: &str) -> Option {
+    let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?;
+    info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))
+}
+
+/// Set (or clear, with an empty value) a document-information entry.
+///
+/// Note: documents with XMP metadata also carry these values in the XMP packet; synchronising
+/// XMP arrives with the `model` crate (M2) — viewers that prefer XMP may still show old values.
+pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
+    let value = value.trim();
+    let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
+    match doc.trailer().get(b"Info").cloned() {
+        Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => {
+            doc.update_dict(r, |d| match entry {
+                Some(v) => d.set(key.as_bytes().to_vec(), v),
+                None => {
+                    d.remove(key.as_bytes());
+                }
+            })?;
+        }
+        _ => {
+            let Some(v) = entry else { return Ok(()) };
+            let mut d = match doc.trailer().get(b"Info") {
+                Some(Object::Dict(d)) => d.clone(),
+                _ => Dict::new(),
+            };
+            d.set(key.as_bytes().to_vec(), v);
+            let r = doc.add(d);
+            doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs
new file mode 100644
index 000000000..07a7bfea5
--- /dev/null
+++ b/crates/organize/src/tests.rs
@@ -0,0 +1,187 @@
+use std::sync::Arc;
+
+use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+
+use super::*;
+
+/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
+/// Resources from an intermediate node; each page's content says which page it is.
+fn fixture() -> Vec {
+    let objs: Vec> = vec![
+        b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),                                                  // 1
+        b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 /MediaBox [0 0 300 400] /Rotate 90 >>".to_vec(), // 2
+        b"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 /Resources << /Font << /F1 10 0 R >> >> >>".to_vec(), // 3
+        b"<< /Type /Page /Parent 3 0 R /Contents 7 0 R >>".to_vec(),                                    // 4
+        b"<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>".to_vec(),                                    // 5
+        b"<< /Type /Page /Parent 2 0 R /Contents 9 0 R /Resources << /Font << /F1 10 0 R >> >> /MediaBox [0 0 500 500] >>".to_vec(), // 6
+        stream(b"BT /F1 12 Tf 20 20 Td (Page 1) Tj ET"),                                                // 7
+        stream(b"BT /F1 12 Tf 20 20 Td (Page 2) Tj ET"),                                                // 8
+        stream(b"BT /F1 12 Tf 20 20 Td (Page 3) Tj ET"),                                                // 9
+        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),                             // 10
+        b"<< /Title (Original) /Producer (fixture) >>".to_vec(),                                        // 11
+    ];
+    let mut out = b"%PDF-1.7\n".to_vec();
+    let mut offsets = Vec::new();
+    for (i, o) in objs.iter().enumerate() {
+        offsets.push(out.len());
+        out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+        out.extend_from_slice(o);
+        out.extend_from_slice(b"\nendobj\n");
+    }
+    let xref = out.len();
+    out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+    for o in offsets {
+        out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+    }
+    out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 11 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+    out
+}
+
+fn stream(body: &[u8]) -> Vec {
+    let mut v = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
+    v.extend_from_slice(body);
+    v.extend_from_slice(b"\nendstream");
+    v
+}
+
+fn open(bytes: Vec) -> Document {
+    Document::open(Arc::new(bytes)).expect("opens")
+}
+
+/// The text label of each page, via its content stream, in order.
+fn labels(doc: &Document) -> Vec {
+    pages(doc)
+        .unwrap()
+        .iter()
+        .map(|p| {
+            let page = doc.get(p.obj);
+            let c = page.as_dict().and_then(|d| d.get(b"Contents").cloned());
+            match c.map(|c| doc.resolve(&c)).as_deref() {
+                Some(Object::Stream(s)) => {
+                    let data = s.decoded().unwrap();
+                    let t = String::from_utf8_lossy(&data);
+                    t.split('(').nth(1).and_then(|x| x.split(')').next()).unwrap_or("").to_string()
+                }
+                _ => "blank".into(),
+            }
+        })
+        .collect()
+}
+
+/// Save incrementally, verify the prefix is untouched, and reopen — both with our reader and with
+/// an independent parser (hayro-syntax) that must agree on the page count.
+fn save_and_reopen(doc: &Document) -> Document {
+    let original = doc.bytes().clone();
+    let bytes = write_incremental(doc, &SaveOptions::default()).expect("saves");
+    assert_eq!(&bytes[..original.len()], &original[..], "incremental save must not modify existing bytes");
+    let reopened = open(bytes.clone());
+    let independent = hayro_syntax::Pdf::new(bytes).expect("hayro opens our output");
+    assert_eq!(independent.pages().len(), page_count(&reopened).unwrap(), "readers agree on page count");
+    reopened
+}
+
+#[test]
+fn walks_nested_tree_in_order() {
+    let doc = open(fixture());
+    assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);
+}
+
+#[test]
+fn rotate_uses_inherited_rotation_and_round_trips() {
+    let mut doc = open(fixture());
+    rotate_pages(&mut doc, &[0, 2], 90).unwrap();
+    rotate_pages(&mut doc, &[2], -270).unwrap(); // page 3: inherited 90 + 90 - 270 = -90 ≡ 270
+    let doc = save_and_reopen(&doc);
+    let rot = |i: usize| {
+        let p = pages(&doc).unwrap()[i].obj;
+        doc.get(p).as_dict().and_then(|d| d.int(b"Rotate"))
+    };
+    assert_eq!(rot(0), Some(180), "inherited 90 + 90");
+    assert_eq!(rot(1), None, "untouched page keeps inheriting");
+    assert_eq!(rot(2), Some(270), "normalised into 0..360");
+}
+
+#[test]
+fn delete_keeps_inherited_attributes_on_survivors() {
+    let mut doc = open(fixture());
+    delete_pages(&mut doc, &[0]).unwrap();
+    let doc = save_and_reopen(&doc);
+    assert_eq!(labels(&doc), ["Page 2", "Page 3"]);
+    // Page 2 inherited Resources (from the intermediate node) and MediaBox/Rotate (from the root);
+    // the tree was flattened, so they must now be on the page itself.
+    let p = doc.get(pages(&doc).unwrap()[0].obj);
+    let d = p.as_dict().unwrap();
+    assert!(d.contains(b"Resources") && d.contains(b"MediaBox"));
+    assert_eq!(d.int(b"Rotate"), Some(90));
+    let root = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap());
+    assert_eq!(root.as_dict().unwrap().int(b"Count"), Some(2));
+}
+
+#[test]
+fn cannot_delete_every_page_or_missing_pages() {
+    let mut doc = open(fixture());
+    assert_eq!(delete_pages(&mut doc, &[0, 1, 2]), Err(OrganizeError::WouldRemoveAllPages));
+    assert_eq!(delete_pages(&mut doc, &[7]), Err(OrganizeError::NoSuchPage(7)));
+    assert_eq!(rotate_pages(&mut doc, &[3], 90), Err(OrganizeError::NoSuchPage(3)));
+    assert!(!doc.is_modified(), "failed operations leave the document untouched");
+}
+
+#[test]
+fn move_pages_reorders() {
+    let mut doc = open(fixture());
+    move_pages(&mut doc, &[2], 0).unwrap();
+    assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
+    move_pages(&mut doc, &[0, 1], 3).unwrap();
+    assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
+    let doc = save_and_reopen(&doc);
+    assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
+}
+
+#[test]
+fn inserts_blank_page() {
+    let mut doc = open(fixture());
+    insert_blank_page(&mut doc, 1, 612.0, 792.0).unwrap();
+    let doc = save_and_reopen(&doc);
+    assert_eq!(labels(&doc), ["Page 1", "blank", "Page 2", "Page 3"]);
+    let hay = hayro_syntax::Pdf::new(doc.bytes().clone()).unwrap();
+    let (w, h) = hay.pages().get(1).unwrap().render_dimensions();
+    assert_eq!((w, h), (612.0, 792.0));
+}
+
+#[test]
+fn document_info_edits_round_trip_with_unicode() {
+    let mut doc = open(fixture());
+    set_info(&mut doc, "Title", "Résumé — 履歴書").unwrap();
+    set_info(&mut doc, "Author", "PrintCraft").unwrap();
+    set_info(&mut doc, "Producer", "").unwrap(); // clearing removes the key
+    let doc = save_and_reopen(&doc);
+    assert_eq!(info(&doc, "Title").as_deref(), Some("Résumé — 履歴書"));
+    assert_eq!(info(&doc, "Author").as_deref(), Some("PrintCraft"));
+    assert_eq!(info(&doc, "Producer"), None);
+}
+
+#[test]
+fn successive_incremental_saves_stack_revisions() {
+    let mut doc = open(fixture());
+    rotate_pages(&mut doc, &[0], 90).unwrap();
+    let doc = save_and_reopen(&doc);
+    assert_eq!(doc.revisions().len(), 2);
+    let mut doc = doc;
+    delete_pages(&mut doc, &[1]).unwrap();
+    let doc = save_and_reopen(&doc);
+    assert_eq!(doc.revisions().len(), 3);
+    assert_eq!(labels(&doc), ["Page 1", "Page 3"]);
+}
+
+#[test]
+fn full_save_drops_unreachable_objects() {
+    let mut doc = open(fixture());
+    delete_pages(&mut doc, &[0, 1]).unwrap();
+    let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+    let full = open(bytes.clone());
+    assert_eq!(labels(&full), ["Page 3"]);
+    // Deleted pages, their content streams and the orphaned intermediate node are gone.
+    assert!(full.object_numbers().len() < doc.object_numbers().len());
+    assert_eq!(info(&full, "Title").as_deref(), Some("Original"));
+    assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1);
+}
diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml
index afadd24af..5e646481e 100644
--- a/crates/render/Cargo.toml
+++ b/crates/render/Cargo.toml
@@ -12,5 +12,10 @@ workspace = true
 hayro = { workspace = true }
 hayro-syntax = { workspace = true }
 lopdf = { workspace = true }
+kurbo = { workspace = true }
 thiserror = { workspace = true }
 log = { workspace = true }
+
+# lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+getrandom = { version = "0.4", features = ["wasm_js"] }
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 9a6eb01ca..e247091b7 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -7,8 +7,12 @@
 use std::collections::{BTreeMap, HashMap, HashSet};
 use std::sync::Arc;
 
+use std::panic::{AssertUnwindSafe, catch_unwind};
+
+use hayro::hayro_syntax::DecryptionError;
+use hayro::hayro_syntax::LoadPdfError;
 use hayro::hayro_syntax::Pdf;
-use lopdf::{Dictionary, Document, Object, ObjectId};
+use lopdf::{Dictionary, Document, LoadOptions, Object, ObjectId};
 
 use crate::OpenError;
 
@@ -31,6 +35,7 @@ pub struct DocInfo {
     pub fields: Vec,
     pub links: Vec,
     pub layers: Vec,
+    pub fonts: Vec,
     pub attachments: Vec,
     pub warnings: Vec,
 }
@@ -112,6 +117,8 @@ pub struct Field {
 
 #[derive(Clone, Debug)]
 pub struct Layer {
+    /// Object number and generation of the optional content group.
+    pub id: (u32, u16),
     pub name: String,
     pub visible: bool,
 }
@@ -121,30 +128,92 @@ pub struct Attachment {
     pub name: String,
     pub description: Option,
     pub size: Option,
+    pub source: AttachmentSource,
 }
 
-/// Inspect a document. Fails only if the renderer itself cannot open the file.
-pub fn inspect(bytes: Arc>) -> Result {
-    let pdf = Pdf::new(bytes.clone()).map_err(|e| match format!("{e:?}") {
-        s if s.to_lowercase().contains("encrypt") || s.to_lowercase().contains("password") => OpenError::NeedsPassword,
-        s => OpenError::Invalid(s),
-    })?;
+/// Where an attachment lives (so its bytes can be fetched on demand).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AttachmentSource {
+    /// Document-level: `/Names /EmbeddedFiles` entry with this key.
+    Document { key: String },
+    /// A FileAttachment annotation: page index and position in the page's `/Annots`.
+    Annotation { page: usize, index: usize },
+}
+
+/// A font used by the document (Document Properties ▸ Fonts).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FontInfo {
+    /// BaseFont without the subset tag.
+    pub name: String,
+    /// Type1, TrueType, Type0, Type3, MMType1, CIDFontType0/2 …
+    pub kind: String,
+    pub embedded: bool,
+    pub subset: bool,
+    pub encoding: Option,
+}
+
+/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a
+/// password). Never panics: parser crashes are caught and reported.
+pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result {
+    let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or(""))))
+        .map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))?
+        .map_err(|e| match e {
+            LoadPdfError::Decryption(DecryptionError::PasswordProtected) => {
+                if password.is_some() {
+                    OpenError::WrongPassword
+                } else {
+                    OpenError::NeedsPassword
+                }
+            }
+            LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")),
+            LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()),
+        })?;
     let mut info = DocInfo { file_size: bytes.len(), pdf_version: format!("{:?}", pdf.version()), ..Default::default() };
-    for (i, page) in pdf.pages().iter().enumerate() {
-        let (w, h) = page.render_dimensions();
-        let c = page.intersected_crop_box();
-        let rotation = match page.rotation() {
-            hayro::hayro_syntax::page::Rotation::None => 0,
-            hayro::hayro_syntax::page::Rotation::Horizontal => 90,
-            hayro::hayro_syntax::page::Rotation::Flipped => 180,
-            hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270,
-        };
-        let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32];
-        info.pages.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation });
+    let pages = catch_unwind(AssertUnwindSafe(|| {
+        let mut out = Vec::new();
+        for (i, page) in pdf.pages().iter().enumerate() {
+            let (w, h) = page.render_dimensions();
+            let c = page.intersected_crop_box();
+            let rotation = match page.rotation() {
+                hayro::hayro_syntax::page::Rotation::None => 0,
+                hayro::hayro_syntax::page::Rotation::Horizontal => 90,
+                hayro::hayro_syntax::page::Rotation::Flipped => 180,
+                hayro::hayro_syntax::page::Rotation::FlippedHorizontal => 270,
+            };
+            let crop = [c.x0 as f32, c.y0 as f32, c.x1 as f32, c.y1 as f32];
+            // Degenerate boxes still get a usable placeholder size so layout never divides by zero.
+            let (w, h) = if w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 { (w, h) } else { (612.0, 792.0) };
+            out.push(PageInfo { width: w, height: h, label: (i + 1).to_string(), crop, rotation });
+        }
+        out
+    }));
+    match pages {
+        Ok(p) => info.pages = p,
+        Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
     }
-    match Document::load_mem(&bytes) {
-        Ok(doc) => Inspector::new(&doc).fill(&mut info),
-        Err(e) => info.warnings.push(format!("structure inspection unavailable: {e}")),
+    let options = LoadOptions { password: password.map(str::to_owned), ..LoadOptions::default() };
+    let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) {
+        Ok(doc) => {
+            let mut tmp = DocInfo::default();
+            std::mem::swap(&mut tmp.pages, &mut info.pages);
+            Inspector::new(&doc).fill(&mut tmp);
+            Ok(tmp)
+        }
+        Err(e) => Err(e.to_string()),
+    }));
+    match structure {
+        Ok(Ok(mut filled)) => {
+            filled.file_size = info.file_size;
+            filled.pdf_version = std::mem::take(&mut info.pdf_version);
+            info = filled;
+        }
+        Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")),
+        Err(p) => {
+            info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p)));
+        }
+    }
+    if info.pages.is_empty() {
+        return Err(OpenError::Invalid("the document has no pages".into()));
     }
     Ok(info)
 }
@@ -194,11 +263,12 @@ impl<'a> Inspector<'a> {
         }
         info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
         self.layers(catalog, &mut info.layers);
+        self.fonts(&mut info.fonts);
         if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
             if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
                 let mut seen = HashSet::new();
                 for (name, spec) in self.name_tree(ef, &mut seen, 0) {
-                    info.attachments.push(self.attachment(name, spec));
+                    info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }));
                 }
             }
             info.has_javascript |= names.get(b"JavaScript").is_ok();
@@ -379,9 +449,15 @@ impl<'a> Inspector<'a> {
         for (&id, &page) in &self.page_index {
             let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
             let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
-            for a in annots {
+            for (index, a) in annots.iter().enumerate() {
                 let Some(d) = self.dict(a) else { continue };
                 let Some(subtype) = self.name(d, b"Subtype") else { continue };
+                if subtype == "FileAttachment"
+                    && let Ok(fs) = d.get(b"FS")
+                {
+                    let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into());
+                    info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
+                }
                 if subtype == "Link" {
                     if let Some(link) = self.link(page, d) {
                         if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
@@ -541,11 +617,55 @@ impl<'a> Inspector<'a> {
                 Some(id) if on.contains(&id) => true,
                 _ => !base_off,
             };
-            out.push(Layer { name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible });
+            let Some(id) = id else { continue };
+            out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible });
         }
     }
 
-    fn attachment(&self, name: String, spec: &Object) -> Attachment {
+    fn fonts(&self, out: &mut Vec) {
+        let mut seen = HashSet::new();
+        let mut pages: Vec<_> = self.page_index.iter().collect();
+        pages.sort_by_key(|(_, i)| **i);
+        for (&pid, _) in pages {
+            let Ok(fonts) = self.doc.get_page_fonts(pid) else { continue };
+            for (_, f) in fonts {
+                let base = f
+                    .get(b"BaseFont")
+                    .ok()
+                    .and_then(|o| o.as_name().ok())
+                    .map(|n| String::from_utf8_lossy(n).into_owned())
+                    .unwrap_or_else(|| "(unnamed)".into());
+                let kind = self.name(f, b"Subtype").unwrap_or_default();
+                let encoding = match f.get(b"Encoding").map(|o| self.resolve(o)) {
+                    Ok(Object::Name(n)) => Some(String::from_utf8_lossy(n).into_owned()),
+                    Ok(Object::Dictionary(_)) => Some("Custom".into()),
+                    Ok(Object::Stream(_)) => Some("Embedded CMap".into()),
+                    _ => None,
+                };
+                // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font).
+                let descriptor_of = |d: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned();
+                let desc = descriptor_of(f).or_else(|| {
+                    f.get(b"DescendantFonts")
+                        .ok()
+                        .and_then(|o| self.resolve(o).as_array().ok())
+                        .and_then(|a| a.first())
+                        .and_then(|o| self.dict(o))
+                        .and_then(descriptor_of)
+                });
+                let embedded =
+                    kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok());
+                let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
+                let name = if subset { base[7..].to_string() } else { base };
+                let info = FontInfo { name, kind, embedded, subset, encoding };
+                if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) {
+                    out.push(info);
+                }
+            }
+        }
+        out.sort_by(|a, b| a.name.cmp(&b.name));
+    }
+
+    fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment {
         let d = self.dict(spec);
         let size =
             d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
@@ -563,7 +683,7 @@ impl<'a> Inspector<'a> {
                 },
             );
         let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
-        Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size }
+        Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source }
     }
 }
 
@@ -620,6 +740,39 @@ fn pretty_date(s: &str) -> String {
     }
 }
 
+/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs.
+pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> {
+    let run = || -> Result, String> {
+        let options = LoadOptions { password: password.map(str::to_owned), ..LoadOptions::default() };
+        let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?;
+        let insp = Inspector::new(&doc);
+        let spec: &Object = match &att.source {
+            AttachmentSource::Document { key } => {
+                let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?;
+                let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?;
+                let mut seen = HashSet::new();
+                insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")?
+            }
+            AttachmentSource::Annotation { page, index } => {
+                let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?;
+                let annots = doc.get_dictionary(pid).map_err(|e| e.to_string())?.get(b"Annots").map_err(|e| e.to_string())?;
+                let a = insp.resolve(annots).as_array().map_err(|e| e.to_string())?.get(*index).ok_or("annotation not found")?;
+                insp.dict(a).and_then(|d| d.get(b"FS").ok()).ok_or("annotation has no file")?
+            }
+        };
+        let fs = insp.dict(spec).ok_or("bad file specification")?;
+        let ef = fs.get(b"EF").ok().and_then(|o| insp.dict(o)).ok_or("the file is not embedded (external reference)")?;
+        let stream = ef.get(b"UF").ok().or_else(|| ef.get(b"F").ok()).map(|o| insp.resolve(o)).ok_or("no embedded stream")?;
+        let stream = stream.as_stream().map_err(|e| e.to_string())?;
+        if stream.dict.get(b"Filter").is_ok() {
+            stream.decompressed_content_with_limit(1 << 30).map_err(|e| e.to_string())
+        } else {
+            Ok(stream.content.clone())
+        }
+    };
+    catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p))))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -633,6 +786,37 @@ mod tests {
         assert_eq!(alpha(53), "aaa");
     }
 
+    const ATTACHMENTS: &[u8] = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 6 0 R] >> >> >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] /Resources << /Font << /F1 9 0 R >> >> >> endobj
+4 0 obj << /Type /Annot /Subtype /FileAttachment /Rect [10 10 30 30] /Contents (data.csv) /FS 8 0 R >> endobj
+5 0 obj << /Type /EmbeddedFile /Length 11 >> stream
+hello notes
+endstream endobj
+6 0 obj << /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 5 0 R >> /Desc (Doc-level) >> endobj
+7 0 obj << /Type /EmbeddedFile /Length 5 >> stream
+a,b,c
+endstream endobj
+8 0 obj << /Type /Filespec /F (data.csv) /EF << /F 7 0 R >> >> endobj
+9 0 obj << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica >> endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+
+    #[test]
+    fn attachments_of_both_kinds_are_listed_and_extracted() {
+        let bytes = Arc::new(ATTACHMENTS.to_vec());
+        let info = inspect(bytes.clone(), None).expect("opens");
+        let names: Vec<_> = info.attachments.iter().map(|a| a.name.as_str()).collect();
+        assert!(names.contains(&"notes.txt") && names.contains(&"data.csv"), "{names:?}");
+        for a in &info.attachments {
+            let data = attachment_data(&bytes, None, a).expect("extracts");
+            let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" };
+            assert_eq!(data, expected, "{}", a.name);
+        }
+        assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]);
+    }
+
     #[test]
     fn dates_are_prettified() {
         assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index 283d41ba1..cc36b39e1 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -8,15 +8,24 @@
 
 mod inspect;
 mod raster;
+pub mod text;
 
-pub use inspect::{Annotation, Attachment, DocInfo, Field, FieldKind, Layer, Link, LinkTarget, OutlineItem, PageInfo, inspect};
-pub use raster::{RenderPool, RenderRequest, RenderedPage};
+pub use inspect::{
+    Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, attachment_data,
+    inspect,
+};
+pub use raster::{MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, effective_scale};
+pub use text::{PageText, TextGlyph};
 
 /// Errors surfaced to the user when a document cannot be opened.
 #[derive(Debug, thiserror::Error)]
 pub enum OpenError {
     #[error("the file is not a readable PDF: {0}")]
     Invalid(String),
-    #[error("the document is encrypted and needs a password")]
+    #[error("the document is protected by a password")]
     NeedsPassword,
+    #[error("the password is incorrect")]
+    WrongPassword,
+    #[error("{0}")]
+    Unsupported(String),
 }
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 2672e5918..ef885236c 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -1,9 +1,14 @@
 //! Background page rasterization.
 //!
 //! A `RenderPool` owns N worker threads. Each worker parses the shared bytes once (hayro's parser
-//! is lazy, so this is cheap) and keeps its own render cache. Requests carry a generation number;
-//! the UI drops results for stale generations (e.g. after a zoom change).
+//! is lazy, so this is cheap) and keeps its own render cache. Requests carry a caller tag that is
+//! echoed back; the UI drops results for stale tags (e.g. after a zoom change).
+//!
+//! **Robustness:** every page render runs under `catch_unwind`. A panic inside the renderer on a
+//! malformed page yields a `RenderedPage` with `error` set, and the worker rebuilds its parser and
+//! cache before continuing, so one bad page can never take down the app or poison later pages.
 
+use std::panic::{AssertUnwindSafe, catch_unwind};
 use std::sync::mpsc::{Receiver, Sender, channel};
 use std::sync::{Arc, Mutex};
 use std::thread::JoinHandle;
@@ -13,24 +18,193 @@ use hayro::hayro_syntax::Pdf;
 use hayro::vello_cpu::color::palette::css::WHITE;
 use hayro::{RenderCache, RenderSettings, render};
 
-/// Hard cap on a rendered side, to bound memory at extreme zoom levels (tiling arrives in M3.3).
-const MAX_SIDE: f32 = 8192.0;
+/// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics).
+#[derive(Clone, Copy)]
+struct Stopwatch(#[cfg(not(target_arch = "wasm32"))] std::time::Instant);
 
-#[derive(Clone, Copy, Debug, PartialEq)]
+impl Stopwatch {
+    fn start() -> Self {
+        #[cfg(not(target_arch = "wasm32"))]
+        return Self(std::time::Instant::now());
+        #[cfg(target_arch = "wasm32")]
+        return Self();
+    }
+    fn millis(self) -> u32 {
+        #[cfg(not(target_arch = "wasm32"))]
+        return self.0.elapsed().as_millis() as u32;
+        #[cfg(target_arch = "wasm32")]
+        return 0;
+    }
+}
+
+/// Per-document rendering configuration.
+#[derive(Clone, Debug, Default)]
+pub struct RenderConfig {
+    /// User or owner password for encrypted documents.
+    pub password: Option>,
+    /// Layer (optional content group) visibility overrides: (object number, generation, visible).
+    pub layers: Arc>,
+}
+
+impl RenderConfig {
+    fn settings(&self) -> InterpreterSettings {
+        InterpreterSettings { ocg_overrides: self.layers.clone(), ..InterpreterSettings::default() }
+    }
+}
+
+/// Hard cap on a rendered side, to bound memory at extreme zoom levels (tiling arrives in M3.3).
+pub const MAX_SIDE: f32 = 8192.0;
+/// Hard cap on rendered pixels per page (~64 MP ≈ 256 MB RGBA).
+pub const MAX_PIXELS: f32 = 64.0e6;
+
+/// What a request asks for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub enum RequestKind {
+    /// A raster of the page.
+    #[default]
+    Pixels,
+    /// The page's text layer (glyphs with Unicode and boxes); `scale` is ignored.
+    Text,
+}
+
+/// A region of a scaled page, in device pixels (for tiled rendering of large pages).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct Tile {
+    pub x: u32,
+    pub y: u32,
+    pub w: u32,
+    pub h: u32,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Default)]
 pub struct RenderRequest {
     pub page: usize,
+    pub kind: RequestKind,
+    /// Render only this region (device pixels at `scale`). `None` renders the whole page.
+    pub tile: Option,
     /// Device pixels per PDF point.
     pub scale: f32,
     /// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page).
     pub tag: u64,
 }
 
+#[derive(Debug)]
 pub struct RenderedPage {
     pub request: RenderRequest,
     pub width: u32,
     pub height: u32,
-    /// Premultiplied RGBA8, row-major.
+    /// Premultiplied RGBA8, row-major. Empty when `error` is set.
     pub rgba: Vec,
+    /// Why the page could not be rendered (renderer panic, empty page box, …).
+    pub error: Option,
+    /// For `RequestKind::Text`.
+    pub text: Option>,
+    pub millis: u32,
+}
+
+/// Clamp a requested scale so the output respects `MAX_SIDE` and `MAX_PIXELS`.
+pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 {
+    let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
+    let by_side = MAX_SIDE / w.max(h);
+    let by_area = (MAX_PIXELS / (w * h)).sqrt();
+    scale.min(by_side).min(by_area).max(0.01)
+}
+
+/// Render one page with a caller-owned parser and cache. Panics inside the renderer are caught
+/// and reported as `Err((message, panicked))`.
+type Output = (u32, u32, Vec, Option>);
+
+fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result {
+    if req.kind == RequestKind::Text {
+        return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
+            Ok(Some(t)) => Ok((0, 0, Vec::new(), Some(Arc::new(t)))),
+            Ok(None) => Err((format!("page {} does not exist", req.page + 1), false)),
+            Err(panic) => Err((format!("text extraction crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
+        };
+    }
+    let result = catch_unwind(AssertUnwindSafe(|| {
+        let pages = pdf.pages();
+        let page = pages.get(req.page).ok_or_else(|| format!("page {} does not exist", req.page + 1))?;
+        let (w, h) = page.render_dimensions();
+        if !(w.is_finite() && h.is_finite()) || w < 0.5 || h < 0.5 {
+            return Err(format!("page {} has an empty or invalid page box ({w}×{h} pt)", req.page + 1));
+        }
+        let rs = match req.tile {
+            // Tiles are bounded by construction, so the page itself may be arbitrarily large.
+            Some(t) => {
+                let scale = req.scale.clamp(0.01, 400.0);
+                let (tw, th) = (t.w.clamp(1, 4096), t.h.clamp(1, 4096));
+                RenderSettings {
+                    x_scale: scale,
+                    y_scale: scale,
+                    width: Some(tw as u16),
+                    height: Some(th as u16),
+                    x_offset: t.x as f32,
+                    y_offset: t.y as f32,
+                    bg_color: WHITE,
+                }
+            }
+            None => {
+                let scale = effective_scale(w, h, req.scale);
+                RenderSettings { x_scale: scale, y_scale: scale, bg_color: WHITE, ..Default::default() }
+            }
+        };
+        let pixmap = render(page, cache, settings, &rs);
+        Ok((pixmap.width() as u32, pixmap.height() as u32, pixmap.data_as_u8_slice().to_vec(), None))
+    }));
+    match result {
+        Ok(Ok(v)) => Ok(v),
+        Ok(Err(e)) => Err((e, false)),
+        Err(panic) => Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
+    }
+}
+
+fn finish(req: RenderRequest, start: Stopwatch, r: Result) -> RenderedPage {
+    let millis = start.millis();
+    match r {
+        Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis },
+        Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(e), text: None, millis },
+    }
+}
+
+/// A single-threaded renderer over one parsed document (used by the CLI and tests).
+pub struct PageRenderer {
+    bytes: Arc>,
+    config: RenderConfig,
+    pdf: Option,
+    settings: InterpreterSettings,
+}
+
+impl PageRenderer {
+    pub fn new(bytes: Arc>, config: RenderConfig) -> Self {
+        let pdf = parse(&bytes, config.password.as_deref());
+        let settings = config.settings();
+        Self { bytes, config, pdf, settings }
+    }
+
+    pub fn page_count(&self) -> usize {
+        self.pdf.as_ref().map(|p| p.pages().len()).unwrap_or(0)
+    }
+
+    /// Render one page. Never panics.
+    pub fn render(&mut self, req: RenderRequest) -> RenderedPage {
+        let start = Stopwatch::start();
+        let Some(pdf) = self.pdf.as_ref() else { return finish(req, start, Err(("the document could not be parsed".into(), false))) };
+        let cache = RenderCache::new();
+        let r = render_page(pdf, &cache, &self.settings, req);
+        if matches!(r, Err((_, true))) {
+            self.pdf = parse(&self.bytes, self.config.password.as_deref());
+        }
+        finish(req, start, r)
+    }
+}
+
+fn parse(bytes: &Arc>, password: Option<&str>) -> Option {
+    catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")).ok())).ok().flatten()
+}
+
+pub(crate) fn panic_message(p: &Box) -> String {
+    p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown panic".into())
 }
 
 type Queue = Arc>>;
@@ -40,34 +214,53 @@ pub struct RenderPool {
     wake: Vec>,
     results: Receiver,
     _workers: Vec>,
+    /// Used when threads are unavailable (wasm32 without atomics, or spawn failure): requests are
+    /// rendered on the calling thread inside `try_recv`, one per call.
+    inline: Option>,
 }
 
 impl RenderPool {
-    pub fn new(bytes: Arc>, threads: usize) -> Self {
+    pub fn new(bytes: Arc>, threads: usize, config: RenderConfig) -> Self {
         let queue: Queue = Arc::new(Mutex::new(Vec::new()));
         let (res_tx, results) = channel();
         let mut wake = Vec::new();
         let mut workers = Vec::new();
-        for i in 0..threads.max(1) {
+        let threads = if cfg!(target_arch = "wasm32") { 0 } else { threads.max(1) };
+        for i in 0..threads {
             let (wtx, wrx) = channel::<()>();
             wake.push(wtx);
             let queue = queue.clone();
             let res_tx = res_tx.clone();
             let bytes = bytes.clone();
-            let handle = std::thread::Builder::new()
-                .name(format!("printcraft-render-{i}"))
-                .spawn(move || worker(bytes, queue, wrx, res_tx))
-                .expect("spawn render worker");
-            workers.push(handle);
+            let config = config.clone();
+            let spawned = std::thread::Builder::new().name(format!("printcraft-render-{i}")).spawn(move || worker(bytes, config, queue, wrx, res_tx));
+            match spawned {
+                Ok(h) => workers.push(h),
+                Err(e) => log::error!("could not spawn render worker {i}: {e}"),
+            }
         }
-        Self { queue, wake, results, _workers: workers }
+        let inline = workers.is_empty().then(|| std::cell::RefCell::new(PageRenderer::new(bytes, config)));
+        Self { queue, wake, results, _workers: workers, inline }
+    }
+
+    /// A pool without worker threads: renders inside `try_recv` (the web path; also used in tests).
+    pub fn new_inline(bytes: Arc>, config: RenderConfig) -> Self {
+        let (_tx, results) = channel();
+        let inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config)));
+        Self { queue: Arc::new(Mutex::new(Vec::new())), wake: Vec::new(), results, _workers: Vec::new(), inline }
+    }
+
+    /// `true` when rendering happens on the caller's thread (no worker threads available).
+    pub fn is_inline(&self) -> bool {
+        self.inline.is_some()
     }
 
     /// Replace the pending queue (most urgent first). In-flight renders are not interrupted.
     pub fn set_queue(&self, mut requests: Vec) {
         requests.reverse(); // workers pop from the end
-        if let Ok(mut q) = self.queue.lock() {
-            *q = requests;
+        match self.queue.lock() {
+            Ok(mut q) => *q = requests,
+            Err(poisoned) => *poisoned.into_inner() = requests,
         }
         for w in &self.wake {
             let _ = w.send(());
@@ -75,32 +268,46 @@ impl RenderPool {
     }
 
     pub fn try_recv(&self) -> Option {
+        if let Some(r) = &self.inline {
+            let next = match self.queue.lock() {
+                Ok(mut q) => q.pop(),
+                Err(poisoned) => poisoned.into_inner().pop(),
+            };
+            return next.map(|req| r.borrow_mut().render(req));
+        }
         self.results.try_recv().ok()
     }
 }
 
-fn worker(bytes: Arc>, queue: Queue, wake: Receiver<()>, out: Sender) {
-    let Ok(pdf) = Pdf::new(bytes) else { return };
-    let settings = InterpreterSettings::default();
-    let cache = RenderCache::new();
-    let pages = pdf.pages();
+fn worker(bytes: Arc>, config: RenderConfig, queue: Queue, wake: Receiver<()>, out: Sender) {
+    let settings = config.settings();
+    // Outer loop: (re)build parser + cache; rebuilt after a renderer panic.
     loop {
-        let next = queue.lock().ok().and_then(|mut q| q.pop());
-        let Some(req) = next else {
-            if wake.recv().is_err() {
-                return; // pool dropped
+        let pdf = parse(&bytes, config.password.as_deref());
+        let cache = RenderCache::new();
+        loop {
+            let next = match queue.lock() {
+                Ok(mut q) => q.pop(),
+                Err(poisoned) => poisoned.into_inner().pop(),
+            };
+            let Some(req) = next else {
+                if wake.recv().is_err() {
+                    return; // pool dropped
+                }
+                continue;
+            };
+            let start = Stopwatch::start();
+            let r = match &pdf {
+                Some(pdf) => render_page(pdf, &cache, &settings, req),
+                None => Err(("the document could not be parsed".into(), false)),
+            };
+            let panicked = matches!(r, Err((_, true)));
+            if out.send(finish(req, start, r)).is_err() {
+                return;
+            }
+            if panicked {
+                break;
             }
-            continue;
-        };
-        let Some(page) = pages.get(req.page) else { continue };
-        let (w, h) = page.render_dimensions();
-        let scale = req.scale.min(MAX_SIDE / w.max(h).max(1.0));
-        let rs = RenderSettings { x_scale: scale, y_scale: scale, bg_color: WHITE, ..Default::default() };
-        let pixmap = render(page, &cache, &settings, &rs);
-        let (width, height) = (pixmap.width() as u32, pixmap.height() as u32);
-        let rgba = pixmap.data_as_u8_slice().to_vec();
-        if out.send(RenderedPage { request: req, width, height, rgba }).is_err() {
-            return;
         }
     }
 }
@@ -121,18 +328,165 @@ trailer << /Root 1 0 R >>
 
     #[test]
     fn renders_a_blue_rectangle() {
-        let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1);
-        pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, tag: 7 }]);
+        let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
+        pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7 }]);
         let page = loop {
             if let Some(p) = pool.try_recv() {
                 break p;
             }
             std::thread::sleep(std::time::Duration::from_millis(5));
         };
+        assert!(page.error.is_none(), "{:?}", page.error);
         assert_eq!((page.width, page.height, page.request.tag), (100, 50, 7));
         // Pixel (20, 30) in y-down device space is inside the rect drawn at y 10..30 (y-up).
         let px = |x: u32, y: u32| &page.rgba[((y * page.width + x) * 4) as usize..][..4];
         assert_eq!(px(20, 30), &[0, 0, 255, 255]);
         assert_eq!(px(80, 5), &[255, 255, 255, 255]);
     }
+
+    #[test]
+    fn missing_page_and_garbage_input_fail_gracefully() {
+        let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
+        assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
+        let mut g = PageRenderer::new(Arc::new(b"not a pdf at all".to_vec()), RenderConfig::default());
+        assert_eq!(g.page_count(), 0);
+        assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
+    }
+
+    #[test]
+    fn scale_is_clamped_for_huge_pages() {
+        // A 200-inch-square page at 4x would be 57,600 px per side.
+        let s = effective_scale(14_400.0, 14_400.0, 4.0);
+        assert!(14_400.0 * s <= MAX_SIDE + 0.5);
+        assert!((14_400.0 * s).powi(2) <= MAX_PIXELS * 1.01);
+    }
+
+    /// A checkbox whose `/AP /N` is a state dictionary must draw the `/AS` state; a NoView
+    /// annotation must not draw at all. (Regression test for the vendored hayro patch.)
+    #[test]
+    fn widget_appearance_states() {
+        let pdf = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R 7 0 R] >> endobj
+4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (cb) /V /Yes /AS /Yes /Rect [10 10 30 30]
+   /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> >> endobj
+5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
+1 0 0 rg 0 0 20 20 re f
+endstream endobj
+6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
+0 0 1 rg 0 0 20 20 re f
+endstream endobj
+7 0 obj << /Type /Annot /Subtype /Square /F 32 /Rect [60 60 90 90] /AP << /N 8 0 R >> >> endobj
+8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream
+0 1 0 rg 0 0 30 30 re f
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+        let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
+        let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
+        assert!(p.error.is_none(), "{:?}", p.error);
+        let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
+        assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show its /Yes state");
+        assert_eq!(px(75, 25), vec![255, 255, 255, 255], "NoView annotation must not be drawn");
+    }
+
+    #[test]
+    fn inline_pool_renders_in_priority_order() {
+        let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
+        assert!(pool.is_inline());
+        assert!(pool.try_recv().is_none());
+        pool.set_queue(vec![
+            RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1 },
+            RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2 },
+        ]);
+        assert_eq!(pool.try_recv().map(|p| p.request.tag), Some(1));
+        assert_eq!(pool.try_recv().map(|p| (p.request.tag, p.width)), Some((2, 50)));
+        assert!(pool.try_recv().is_none());
+    }
+
+    #[test]
+    fn extracts_text_with_positions() {
+        let pdf = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
+4 0 obj << /Length 60 >> stream
+BT /F1 12 Tf 20 70 Td (Hello World) Tj 0 -30 Td (Second) Tj ET
+endstream endobj
+5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+        let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
+        let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
+        assert!(out.error.is_none(), "{:?}", out.error);
+        let t = out.text.expect("text");
+        assert_eq!(t.plain_text(), "Hello World\nSecond");
+        // "H" sits at x=20, baseline y=70 (y-up) → view y ≈ 100-70 = 30 at the baseline.
+        let h = &t.glyphs[0].rect;
+        assert!((h[0] - 20.0).abs() < 0.5 && h[1] < 30.0 && h[3] > 30.0, "{h:?}");
+        assert_eq!(t.find("world").len(), 1);
+    }
+
+    #[test]
+    fn layer_override_hides_content() {
+        let pdf = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj
+4 0 obj << /Length 44 >> stream
+/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC
+endstream endobj
+5 0 obj << /Type /OCG /Name (Red) >> endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+        let px = |cfg: RenderConfig| {
+            let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), cfg);
+            let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 });
+            p.rgba[..4].to_vec()
+        };
+        assert_eq!(px(RenderConfig::default()), vec![255, 0, 0, 255], "layer is on by default");
+        let off = RenderConfig { layers: Arc::new(vec![(5, 0, false)]), ..Default::default() };
+        assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw");
+    }
+
+    #[test]
+    fn tiles_match_full_render() {
+        let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
+        let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
+        let tile = Tile { x: 30, y: 40, w: 50, h: 30 };
+        let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0 });
+        assert_eq!((part.width, part.height), (50, 30));
+        for y in 0..30u32 {
+            for x in 0..50u32 {
+                let a = &full.rgba[(((y + 40) * full.width + x + 30) * 4) as usize..][..4];
+                let b = &part.rgba[((y * 50 + x) * 4) as usize..][..4];
+                assert_eq!(a, b, "pixel {x},{y}");
+            }
+        }
+    }
+
+    /// An alpha soft mask whose transparency group has no /CS (as Chrome writes gradient text)
+    /// must still mask. Regression test for the vendored hayro-interpret patch.
+    #[test]
+    fn soft_mask_without_group_colour_space_is_applied() {
+        let pdf = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj
+4 0 obj << /Length 31 >> stream
+/M gs 1 0 0 rg 0 0 100 100 re f
+endstream endobj
+5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 6 0 R >> >> endobj
+6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >> stream
+0 g 0 0 50 100 re f
+endstream endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+        let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
+        let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
+        let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
+        assert_eq!(px(25, 50), vec![255, 0, 0, 255], "inside the mask");
+        assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask");
+    }
 }
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
new file mode 100644
index 000000000..0b7316a2c
--- /dev/null
+++ b/crates/render/src/text.rs
@@ -0,0 +1,512 @@
+//! Text extraction (bootstrap for `printcraft-text`, architecture §8).
+//!
+//! A hayro `Device` that ignores paint and records every glyph: its Unicode value (ToUnicode →
+//! encoding → glyph name fallbacks, handled by hayro) and its box in *page view space*, i.e.
+//! points with the origin at the top-left of the displayed page, rotation applied, y down. The UI
+//! maps view space to the screen with a single scale.
+//!
+//! On top of the glyph list: line grouping (baseline clustering), word gaps, plain-text output,
+//! case-insensitive search and reading-order selection.
+
+use hayro::hayro_interpret::font::Glyph;
+use hayro::hayro_interpret::hayro_syntax::Pdf;
+use hayro::hayro_interpret::{
+    BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page,
+};
+use kurbo::{Affine, BezPath, Rect, Shape};
+
+/// One glyph on the page.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextGlyph {
+    pub text: String,
+    /// Box in page view space (points, y down): [x0, y0, x1, y1].
+    pub rect: [f32; 4],
+}
+
+/// The text of one page, in content order, with line structure.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct PageText {
+    pub glyphs: Vec,
+    /// For each glyph: its line index (lines are in reading order, top to bottom).
+    pub line_of: Vec,
+    /// `true` when a word break precedes the glyph on the same line.
+    pub space_before: Vec,
+}
+
+impl PageText {
+    pub fn is_empty(&self) -> bool {
+        self.glyphs.is_empty()
+    }
+
+    /// Text of glyphs `range` with spaces and line breaks reconstructed.
+    pub fn text_of(&self, range: std::ops::Range) -> String {
+        let mut s = String::new();
+        let mut prev_line = None;
+        for i in range.clone() {
+            let Some(g) = self.glyphs.get(i) else { break };
+            if i != range.start {
+                if prev_line != Some(self.line_of[i]) {
+                    s.push('\n');
+                } else if self.space_before[i] {
+                    s.push(' ');
+                }
+            }
+            s.push_str(&g.text);
+            prev_line = Some(self.line_of[i]);
+        }
+        s
+    }
+
+    pub fn plain_text(&self) -> String {
+        self.text_of(0..self.glyphs.len())
+    }
+
+    /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks,
+    /// which count as a single space).
+    pub fn find(&self, needle: &str) -> Vec> {
+        let needle: Vec = needle.to_lowercase().split_whitespace().collect::>().join(" ").chars().collect();
+        if needle.is_empty() {
+            return Vec::new();
+        }
+        // Flatten to chars with the glyph index each char came from.
+        let mut chars: Vec<(char, usize)> = Vec::new();
+        for (i, g) in self.glyphs.iter().enumerate() {
+            if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| c.0 != ' ') {
+                chars.push((' ', i));
+            }
+            for c in g.text.chars().flat_map(char::to_lowercase) {
+                chars.push((if c.is_whitespace() { ' ' } else { c }, i));
+            }
+        }
+        let mut out = Vec::new();
+        let mut i = 0;
+        while i + needle.len() <= chars.len() {
+            if chars[i..i + needle.len()].iter().map(|c| c.0).eq(needle.iter().copied()) {
+                let start = chars[i].1;
+                let end = chars[i + needle.len() - 1].1 + 1;
+                out.push(start..end);
+                i += needle.len();
+            } else {
+                i += 1;
+            }
+        }
+        out
+    }
+
+    /// Index of the glyph nearest to a view-space point (for selection anchors).
+    pub fn nearest(&self, x: f32, y: f32) -> Option {
+        self.glyphs
+            .iter()
+            .enumerate()
+            .map(|(i, g)| {
+                let dx = if x < g.rect[0] {
+                    g.rect[0] - x
+                } else if x > g.rect[2] {
+                    x - g.rect[2]
+                } else {
+                    0.0
+                };
+                let dy = if y < g.rect[1] {
+                    g.rect[1] - y
+                } else if y > g.rect[3] {
+                    y - g.rect[3]
+                } else {
+                    0.0
+                };
+                // Prefer the same line: vertical distance weighs more.
+                (i, dx + dy * 3.0)
+            })
+            .min_by(|a, b| a.1.total_cmp(&b.1))
+            .map(|(i, _)| i)
+    }
+
+    /// Merge the boxes of `range` into one rectangle per line (for highlighting).
+    pub fn line_rects(&self, range: std::ops::Range) -> Vec<[f32; 4]> {
+        let mut out: Vec<(u32, [f32; 4])> = Vec::new();
+        for i in range {
+            let Some(g) = self.glyphs.get(i) else { break };
+            let line = self.line_of[i];
+            match out.last_mut() {
+                Some((l, r)) if *l == line => {
+                    r[0] = r[0].min(g.rect[0]);
+                    r[1] = r[1].min(g.rect[1]);
+                    r[2] = r[2].max(g.rect[2]);
+                    r[3] = r[3].max(g.rect[3]);
+                }
+                _ => out.push((line, g.rect)),
+            }
+        }
+        out.into_iter().map(|(_, r)| r).collect()
+    }
+}
+
+struct TextDevice {
+    glyphs: Vec,
+}
+
+impl<'a> Device<'a> for TextDevice {
+    fn set_soft_mask(&mut self, _: Option>) {}
+    fn set_blend_mode(&mut self, _: BlendMode) {}
+    fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
+    fn push_clip_path(&mut self, _: &ClipPath) {}
+    fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {}
+    fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {
+        let Some(u) = glyph.as_unicode() else {
+            if std::env::var_os("PRINTCRAFT_TEXT_DEBUG").is_some() {
+                eprintln!("NOUNICODE {} {:?}", if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" }, (transform * glyph_transform).as_coeffs());
+            }
+            return;
+        };
+        let text = match u {
+            hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(),
+            hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s,
+        };
+        if text.chars().all(|c| c.is_control()) {
+            return;
+        }
+        // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em
+        // box spans descender (−200) to ascender (800), the advance gives the width.
+        let t = transform * glyph_transform;
+        if std::env::var_os("PRINTCRAFT_TEXT_DEBUG").is_some() {
+            let kind = if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" };
+            eprintln!("{kind} {text:?} transform={:?} glyph_transform={:?}", transform.as_coeffs(), glyph_transform.as_coeffs());
+        }
+        let advance = match glyph {
+            Glyph::Outline(g) => {
+                g.advance_width().filter(|a| *a > 0.0).map(f64::from).unwrap_or_else(|| g.outline().bounding_box().width().max(500.0))
+            }
+            Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0),
+        };
+        let em = Rect::new(0.0, -200.0, advance, 800.0);
+        let b = (t * em.to_path(0.1)).bounding_box();
+        if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 {
+            return;
+        }
+        for (i, ch) in text.chars().enumerate() {
+            // Ligatures (e.g. "ffi") share the glyph box, split evenly.
+            let n = text.chars().count().max(1) as f64;
+            let w = b.width() / n;
+            let x0 = b.x0 + w * i as f64;
+            self.glyphs.push(TextGlyph { text: ch.to_string(), rect: [x0 as f32, b.y0 as f32, (x0 + w) as f32, b.y1 as f32] });
+        }
+    }
+    fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
+    fn pop_clip_path(&mut self) {}
+    fn pop_transparency_group(&mut self) {}
+}
+
+/// Extract the text of one page. Panics inside the interpreter are the caller's to catch.
+pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSettings) -> Option {
+    let pages = pdf.pages();
+    let p = pages.get(page)?;
+    let (w, h) = p.render_dimensions();
+    let cache = InterpreterCache::new();
+    // Annotation appearances are included: form-field values and comment text are searchable,
+    // as in Acrobat, and match what other extractors report.
+    let settings = settings.clone();
+    let initial = p.initial_transform(true).to_kurbo();
+    let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings);
+    let mut dev = TextDevice { glyphs: Vec::new() };
+    interpret_page(p, &mut ctx, &mut dev);
+    Some(layout(dev.glyphs))
+}
+
+use hayro::hayro_interpret::TransformExt;
+
+fn is_rtl(c: char) -> bool {
+    matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF)
+}
+
+/// Scripts written with explicit spaces whose combining signs can sit apart from their base
+/// (Indic, Thai, Lao, Myanmar, Khmer): never infer a space inside a tight cluster.
+fn is_complex(c: char) -> bool {
+    matches!(c as u32, 0x0300..=0x036F | 0x0900..=0x0DFF | 0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1780..=0x17FF)
+}
+
+fn is_cjk(c: char) -> bool {
+    matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3134F | 0x1100..=0x11FF)
+}
+
+struct Seg {
+    idx: Vec,
+    bbox: [f32; 4],
+    h: f32,
+}
+
+impl Seg {
+    fn cy(&self) -> f32 {
+        (self.bbox[1] + self.bbox[3]) / 2.0
+    }
+}
+
+fn union(a: &mut [f32; 4], b: &[f32; 4]) {
+    a[0] = a[0].min(b[0]);
+    a[1] = a[1].min(b[1]);
+    a[2] = a[2].max(b[2]);
+    a[3] = a[3].max(b[3]);
+}
+
+/// Reading-order layout. Glyphs are reordered so that `glyphs` is in reading order, lines are
+/// numbered in that order, and word gaps are inferred per line.
+///
+/// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8):
+/// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap.
+///    Segments on the same baseline that nearly touch are merged (text drawn out of order).
+/// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally.
+/// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left
+///    that overlaps it vertically (so columns read left, then right).
+/// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the
+///    text comes out in logical order.
+pub fn layout(glyphs: Vec) -> PageText {
+    // Drop "fake bold" duplicates: the same character redrawn at (almost) the same place.
+    let mut glyphs = glyphs;
+    let mut keep = vec![true; glyphs.len()];
+    for i in 1..glyphs.len() {
+        let g = &glyphs[i];
+        let h = (g.rect[3] - g.rect[1]).max(0.1);
+        for j in (i.saturating_sub(4)..i).rev() {
+            let p = &glyphs[j];
+            if keep[j] && p.text == g.text && (p.rect[0] - g.rect[0]).abs() < h * 0.2 && (p.rect[1] - g.rect[1]).abs() < h * 0.2 {
+                keep[i] = false;
+                break;
+            }
+        }
+    }
+    if keep.iter().any(|k| !k) {
+        let mut it = keep.iter();
+        glyphs.retain(|_| *it.next().unwrap_or(&true));
+    }
+    let n = glyphs.len();
+    if n == 0 {
+        return PageText::default();
+    }
+    let height = |i: usize| (glyphs[i].rect[3] - glyphs[i].rect[1]).max(0.1);
+    let cy = |i: usize| (glyphs[i].rect[1] + glyphs[i].rect[3]) / 2.0;
+
+    // 1. Segments in content order.
+    let mut segs: Vec = Vec::new();
+    for i in 0..n {
+        let g = glyphs[i].rect;
+        let cont = segs.last().is_some_and(|s| {
+            let p = *s.idx.last().expect("non-empty");
+            let ph = height(p).min(height(i));
+            let gap = g[0] - glyphs[p].rect[2];
+            (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
+        });
+        if cont {
+            let s = segs.last_mut().expect("non-empty");
+            s.idx.push(i);
+            union(&mut s.bbox, &g);
+            s.h = s.h.max(height(i));
+        } else {
+            segs.push(Seg { idx: vec![i], bbox: g, h: height(i) });
+        }
+    }
+    // Merge same-baseline segments that nearly touch.
+    let mut merged = true;
+    while merged {
+        merged = false;
+        'outer: for a in 0..segs.len() {
+            for b in 0..segs.len() {
+                if a == b {
+                    continue;
+                }
+                let (sa, sb) = (&segs[a], &segs[b]);
+                let h = sa.h.min(sb.h);
+                let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
+                if (sa.cy() - sb.cy()).abs() < h * 0.3 && (sa.h / sb.h - 1.0).abs() < 0.35 && gap < h * 1.2 {
+                    let sb = segs.remove(b);
+                    let a = if b < a { a - 1 } else { a };
+                    let sa = &mut segs[a];
+                    sa.idx.extend(sb.idx);
+                    union(&mut sa.bbox, &sb.bbox);
+                    sa.h = sa.h.max(sb.h);
+                    merged = true;
+                    break 'outer;
+                }
+            }
+        }
+    }
+    for s in &mut segs {
+        s.idx.sort_by(|a, b| glyphs[*a].rect[0].total_cmp(&glyphs[*b].rect[0]));
+    }
+
+    // 2. Blocks.
+    let mut by_top: Vec = (0..segs.len()).collect();
+    by_top.sort_by(|a, b| segs[*a].bbox[1].total_cmp(&segs[*b].bbox[1]));
+    let mut blocks: Vec<(Vec, [f32; 4])> = Vec::new();
+    for si in by_top {
+        let s = &segs[si];
+        let target = blocks.iter().position(|(members, bb)| {
+            let last = &segs[*members.last().expect("non-empty")];
+            let vgap = s.bbox[1] - last.bbox[3];
+            let overlap = s.bbox[2].min(bb[2]) - s.bbox[0].max(bb[0]);
+            let minw = (s.bbox[2] - s.bbox[0]).min(bb[2] - bb[0]).max(1.0);
+            vgap > -last.h * 0.5 && vgap < last.h.max(s.h) * 1.1 && overlap > minw * 0.3 && (last.h / s.h - 1.0).abs() < 0.6
+        });
+        match target {
+            Some(b) => {
+                blocks[b].0.push(si);
+                let bb = s.bbox;
+                union(&mut blocks[b].1, &bb);
+            }
+            None => blocks.push((vec![si], s.bbox)),
+        }
+    }
+
+    // 3. Block order.
+    let mut remaining: Vec = (0..blocks.len()).collect();
+    let mut block_order = Vec::with_capacity(blocks.len());
+    while !remaining.is_empty() {
+        let top = *remaining.iter().min_by(|a, b| blocks[**a].1[1].total_cmp(&blocks[**b].1[1])).expect("non-empty");
+        let tb = blocks[top].1;
+        let pick = remaining
+            .iter()
+            .copied()
+            .filter(|c| {
+                let cb = blocks[*c].1;
+                let v = cb[3].min(tb[3]) - cb[1].max(tb[1]);
+                cb[2] <= tb[0] + 1.0 && v > 0.5 * (cb[3] - cb[1]).min(tb[3] - tb[1])
+            })
+            .min_by(|a, b| blocks[*a].1[0].total_cmp(&blocks[*b].1[0]))
+            .unwrap_or(top);
+        remaining.retain(|r| *r != pick);
+        block_order.push(pick);
+    }
+
+    // 4. Emit glyphs in reading order with line numbers and word gaps.
+    let mut order: Vec = Vec::with_capacity(n);
+    let mut line_of = Vec::with_capacity(n);
+    let mut space_before = Vec::with_capacity(n);
+    let mut line = 0u32;
+    for b in block_order {
+        for si in &blocks[b].0 {
+            let s = &segs[*si];
+            let mut idx = s.idx.clone();
+            // Reverse right-to-left runs (visual → logical).
+            let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl);
+            let mut k = 0;
+            while k < idx.len() {
+                if rtl(idx[k]) {
+                    let mut e = k;
+                    while e + 1 < idx.len()
+                        && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2])))
+                    {
+                        e += 1;
+                    }
+                    idx[k..=e].reverse();
+                    k = e + 1;
+                } else {
+                    k += 1;
+                }
+            }
+            // Word gaps relative to this line's typical letter gap (handles tracking).
+            let mut gaps: Vec = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect();
+            gaps.sort_by(f32::total_cmp);
+            let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0);
+            let threshold = (typical + s.h * 0.15).max(s.h * 0.15);
+            let mut spaces = vec![false; idx.len()];
+            for w in 1..s.idx.len() {
+                let (p, c) = (s.idx[w - 1], s.idx[w]);
+                let gap = glyphs[c].rect[0] - glyphs[p].rect[2];
+                let (pt, ct) = (&glyphs[p].text, &glyphs[c].text);
+                let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5;
+                let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6;
+                if gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty() {
+                    // Mark the space before glyph `c` wherever it ended up after RTL reversal.
+                    if let Some(pos) = idx.iter().position(|x| *x == c.max(p)) {
+                        spaces[pos] = true;
+                    }
+                }
+            }
+            for (k, g) in idx.iter().enumerate() {
+                order.push(*g);
+                line_of.push(line);
+                space_before.push(k > 0 && spaces[k]);
+            }
+            line += 1;
+        }
+    }
+    let mut slots: Vec> = glyphs.into_iter().map(Some).collect();
+    let glyphs = order.iter().map(|i| slots[*i].take().expect("each glyph emitted once")).collect();
+    PageText { glyphs, line_of, space_before }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph {
+        TextGlyph { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }
+    }
+
+    #[test]
+    fn lines_words_and_search() {
+        // "Hello world" on line 1, "Next" on line 2.
+        let mut v = Vec::new();
+        for (i, c) in "Hello".chars().enumerate() {
+            v.push(g(&c.to_string(), 10.0 + i as f32 * 6.0, 10.0, 16.0 + i as f32 * 6.0));
+        }
+        for (i, c) in "world".chars().enumerate() {
+            v.push(g(&c.to_string(), 50.0 + i as f32 * 6.0, 10.0, 56.0 + i as f32 * 6.0));
+        }
+        for (i, c) in "Next".chars().enumerate() {
+            v.push(g(&c.to_string(), 10.0 + i as f32 * 6.0, 30.0, 16.0 + i as f32 * 6.0));
+        }
+        let t = layout(v);
+        assert_eq!(t.plain_text(), "Hello world\nNext");
+        assert_eq!(t.find("WORLD next"), vec![5..14]);
+        assert_eq!(t.find("o"), vec![4..5, 6..7]);
+        assert_eq!(t.line_rects(3..12).len(), 2);
+        assert_eq!(t.nearest(52.0, 14.0), Some(5));
+    }
+
+    fn word(v: &mut Vec, s: &str, x: f32, y: f32, advance: f32) {
+        for (i, c) in s.chars().enumerate() {
+            let x0 = x + i as f32 * advance;
+            v.push(g(&c.to_string(), x0, y, x0 + 6.0));
+        }
+    }
+
+    #[test]
+    fn fake_bold_duplicates_are_removed() {
+        let mut v = Vec::new();
+        for (i, c) in "Bold".chars().enumerate() {
+            let x = 10.0 + i as f32 * 7.0;
+            v.push(g(&c.to_string(), x, 10.0, x + 6.0));
+            v.push(g(&c.to_string(), x + 0.3, 10.0, x + 6.3));
+        }
+        assert_eq!(layout(v).plain_text(), "Bold");
+    }
+
+    #[test]
+    fn two_columns_read_left_then_right_even_if_drawn_interleaved() {
+        let mut v = Vec::new();
+        // Drawn row by row across both columns, as some producers do.
+        word(&mut v, "Left1", 10.0, 10.0, 6.0);
+        word(&mut v, "Right1", 200.0, 10.0, 6.0);
+        word(&mut v, "Left2", 10.0, 24.0, 6.0);
+        word(&mut v, "Right2", 200.0, 24.0, 6.0);
+        let t = layout(v);
+        assert_eq!(t.plain_text(), "Left1\nLeft2\nRight1\nRight2");
+    }
+
+    #[test]
+    fn tracked_text_keeps_letters_together() {
+        let mut v = Vec::new();
+        word(&mut v, "CHAPTER", 10.0, 10.0, 9.0); // 3pt tracking between 6pt-wide letters
+        word(&mut v, "FOUR", 10.0 + 7.0 * 9.0 + 5.0, 10.0, 9.0);
+        assert_eq!(layout(v).plain_text(), "CHAPTER FOUR");
+    }
+
+    #[test]
+    fn rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces() {
+        let mut v = Vec::new();
+        // Hebrew "שלום" drawn visually right-to-left: ם ו ל ש from left to right.
+        word(&mut v, "םולש", 10.0, 10.0, 6.0);
+        assert_eq!(layout(v).plain_text(), "שלום");
+        let mut v = Vec::new();
+        word(&mut v, "每个字", 10.0, 10.0, 9.0);
+        assert_eq!(layout(v).plain_text(), "每个字");
+    }
+}
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index 4d29ff6ed..bd9686a4f 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -13,8 +13,19 @@ printcraft-engine = { workspace = true }
 printcraft-render = { workspace = true }
 egui = { workspace = true }
 egui_extras = { workspace = true }
-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence"] }
 serde = { workspace = true }
+serde_json = { workspace = true }
+log = { workspace = true }
 
-[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 rfd = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures = "0.4"
+wasm-bindgen = "0.2"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] }
+
+[dev-dependencies]
+egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
+image = { version = "0.25", default-features = false, features = ["png"] }
diff --git a/crates/ui-egui/examples/shot.rs b/crates/ui-egui/examples/shot.rs
new file mode 100644
index 000000000..92ff8d086
--- /dev/null
+++ b/crates/ui-egui/examples/shot.rs
@@ -0,0 +1,64 @@
+//! Headless screenshot of the real PrintCraft shell (egui_kittest + wgpu, no window needed).
+//!
+//! ```text
+//! cargo run -p printcraft-ui-egui --example shot -- out.png [file.pdf] [--size 1440x900] [--scale 2] [--page 3 --panel pages …]
+//! ```
+//! Any `--key value` pair other than size/scale is passed to `PrintCraftApp::set_option`
+//! (the same verbs as the desktop app's command-line flags and the future control channel).
+
+use std::time::{Duration, Instant};
+
+use egui_kittest::Harness;
+use printcraft_ui_egui::PrintCraftApp;
+
+fn main() -> Result<(), String> {
+    let mut args = std::env::args().skip(1);
+    let out = args.next().ok_or("usage: shot  [file.pdf] [--option value …]")?;
+    let (mut file, mut opts) = (None, Vec::new());
+    let (mut size, mut scale) = (egui::vec2(1440.0, 900.0), 2.0f32);
+    while let Some(a) = args.next() {
+        match a.strip_prefix("--") {
+            Some("size") => {
+                let v = args.next().unwrap_or_default();
+                let (w, h) = v.split_once('x').ok_or("--size WxH")?;
+                size = egui::vec2(w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?);
+            }
+            Some("scale") => scale = args.next().and_then(|v| v.parse().ok()).ok_or("bad --scale")?,
+            Some(k) => opts.push((k.to_string(), args.next().unwrap_or_default())),
+            None => file = Some(a),
+        }
+    }
+    let mut harness = Harness::builder().with_size(size).with_pixels_per_point(scale).build_eframe(move |_cc| {
+        let mut app = PrintCraftApp::new();
+        if let Some(f) = &file {
+            let bytes = std::fs::read(f).unwrap_or_default();
+            let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+            if let Err(e) = app.open_bytes(&name, Some(f.clone()), bytes) {
+                eprintln!("shot: {f}: {e}");
+            }
+        }
+        for (k, v) in &opts {
+            if let Err(e) = app.set_option(k, v) {
+                eprintln!("shot: --{k} {v}: {e}");
+            }
+        }
+        app
+    });
+    // Let fonts install, layout settle and background renders arrive.
+    let start = Instant::now();
+    harness.run_steps(4);
+    while start.elapsed() < Duration::from_secs(20) {
+        harness.run_steps(2);
+        if !harness.state().render_pending() {
+            harness.run_steps(3);
+            if !harness.state().render_pending() {
+                break;
+            }
+        }
+        std::thread::sleep(Duration::from_millis(30));
+    }
+    let image = harness.render()?;
+    image.save(&out).map_err(|e| e.to_string())?;
+    eprintln!("shot: wrote {out} ({}×{})", image.width(), image.height());
+    Ok(())
+}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 71b951d5a..d266595ba 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4,14 +4,16 @@
 //! Page images come from the engine's render pool as whole-page rasters at the current zoom;
 //! stale rasters are shown stretched until the sharp one arrives (tiling comes in M3.3).
 
-use std::collections::HashMap;
+use std::collections::{BTreeSet, HashMap, HashSet};
+use std::ops::Range;
+use std::sync::Arc;
 
 use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};
-use printcraft_engine::DocId;
-use printcraft_render::{DocInfo, LinkTarget, RenderPool, RenderRequest};
+use printcraft_engine::{DocId, Edit};
+use printcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile};
 
 use crate::theme::{self, Tokens};
-use crate::{PrintCraftApp, QuickTool, RightPanel, icons};
+use crate::{PrintCraftApp, QuickTool, RightPanel, icons, widgets};
 
 /// Logical pixels per PDF point at 100% (96 dpi, like browsers).
 pub const PT: f32 = 96.0 / 72.0;
@@ -20,6 +22,12 @@ const MARGIN: f32 = 28.0;
 /// Horizontal gutter that keeps pages clear of the floating quick-action bar.
 const SIDE: f32 = 70.0;
 const THUMB_TAG: u64 = 1 << 63;
+const TEXT_TAG: u64 = 1 << 62;
+/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
+const TILE_THRESHOLD: f32 = 4096.0;
+const TILE: u32 = 1024;
+/// Longest side of the low-resolution backdrop drawn under tiles.
+const BASE_SIDE: f32 = 2048.0;
 const THUMB_W: f32 = 132.0;
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,6 +44,31 @@ pub enum PageLayout {
     Single,
 }
 
+/// The find bar (⌘F): query, matches across the document, current match.
+#[derive(Default)]
+pub struct Find {
+    pub query: String,
+    /// (page, glyph range) in document order.
+    pub matches: Vec<(usize, Range)>,
+    pub current: Option,
+    pub focus: bool,
+    pub case_query: String,
+}
+
+/// A text selection on one page, in reading-order glyph indices.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Selection {
+    page: usize,
+    anchor: usize,
+    head: usize,
+}
+
+impl Selection {
+    fn range(&self) -> Range {
+        self.anchor.min(self.head)..self.anchor.max(self.head) + 1
+    }
+}
+
 struct PageTex {
     tag: u64,
     tex: TextureHandle,
@@ -46,6 +79,8 @@ pub struct DocView {
     pub zoom: f32,
     pub fit: Fit,
     pub layout: PageLayout,
+    /// View rotation in degrees clockwise (0, 90, 180, 270); display only, never saved.
+    pub rotation: u16,
     pub current: usize,
     pub organize: bool,
     pub highlight_fields: bool,
@@ -56,11 +91,37 @@ pub struct DocView {
     /// Briefly outline an annotation after navigating to it from a panel.
     pub flash: Option<(usize, [f32; 4], f64)>,
     pages: HashMap,
+    /// Pages the renderer could not draw, with the reason (never re-requested).
+    errors: HashMap,
+    /// When each still-missing page was first shown, to flag unusually slow renders.
+    waiting_since: HashMap,
     thumbs: HashMap,
+    /// Sharp tiles of large pages: (page, tile x, tile y) → (scale tag, texture).
+    tiles: HashMap<(usize, u32, u32), (u64, TextureHandle)>,
+    /// Text layers, extracted in the background on demand (selection, find, copy).
+    texts: HashMap>,
+    text_failed: HashSet,
+    pub find: Option,
+    selection: Option,
     last_queue: Vec,
     viewport_w: f32,
     viewport_h: f32,
     page_count: usize,
+    /// Page heights in points (view space), for mapping text positions to scroll offsets.
+    page_heights: Vec,
+    /// Screen rects of the pages drawn last frame (hit-testing, tests, automation).
+    screen_rects: Vec<(usize, Rect)>,
+    screen_xforms: Vec<(usize, PageXform)>,
+    /// The scroll viewport on screen last frame.
+    viewport_screen: Rect,
+    /// Pending zoom anchor: page, position within it (0..1), and offset from the viewport corner.
+    zoom_anchor: Option<(usize, f32, f32, Vec2)>,
+    /// Pages selected in the organize grid (0-based). Empty means "the current page".
+    pub selected: BTreeSet,
+    /// Anchor for ⇧-click range selection in the organize grid.
+    select_anchor: Option,
+    /// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
+    pub pending_edit: Option,
 }
 
 impl DocView {
@@ -70,6 +131,7 @@ impl DocView {
             zoom: 1.0,
             fit: Fit::Width,
             layout: PageLayout::Continuous,
+            rotation: 0,
             current: 0,
             organize: false,
             highlight_fields: false,
@@ -78,14 +140,165 @@ impl DocView {
             goto: None,
             flash: None,
             pages: HashMap::new(),
+            errors: HashMap::new(),
+            waiting_since: HashMap::new(),
             thumbs: HashMap::new(),
+            tiles: HashMap::new(),
+            texts: HashMap::new(),
+            text_failed: HashSet::new(),
+            find: None,
+            selection: None,
             last_queue: Vec::new(),
             viewport_w: 800.0,
             viewport_h: 600.0,
             page_count: info.pages.len(),
+            page_heights: info.pages.iter().map(|p| p.height).collect(),
+            screen_rects: Vec::new(),
+            screen_xforms: Vec::new(),
+            viewport_screen: Rect::NOTHING,
+            zoom_anchor: None,
+            selected: BTreeSet::new(),
+            select_anchor: None,
+            pending_edit: None,
         }
     }
 
+    /// The document's content or page list changed (an edit, undo or redo): drop caches and
+    /// adopt the new page geometry, keeping the reader's place where possible.
+    pub fn document_changed(&mut self, info: &DocInfo) {
+        self.invalidate_content();
+        self.page_count = info.pages.len();
+        self.page_heights = info.pages.iter().map(|p| p.height).collect();
+        let last = self.page_count.saturating_sub(1);
+        self.current = self.current.min(last);
+        self.page_input = (self.current + 1).to_string();
+        self.selected.retain(|p| *p <= last);
+        if self.select_anchor.is_some_and(|a| a > last) {
+            self.select_anchor = None;
+        }
+        self.goto = None;
+        self.zoom_anchor = None;
+        self.flash = None;
+    }
+
+    /// Pages an organize command acts on: the selection, or the current page.
+    pub fn target_pages(&self) -> Vec {
+        if self.selected.is_empty() { vec![self.current] } else { self.selected.iter().copied().collect() }
+    }
+
+    /// Select pages in the organize grid (tests, automation). Empty clears the selection.
+    pub fn select_pages(&mut self, pages: &[usize]) {
+        self.selected = pages.iter().copied().filter(|p| *p < self.page_count).collect();
+        if let Some(first) = self.selected.first() {
+            self.current = *first;
+        }
+    }
+
+    pub fn render_pending(&self) -> bool {
+        !self.last_queue.is_empty()
+    }
+
+    /// Drop every cached raster and text layer (the document's appearance changed, e.g. a layer
+    /// was toggled). Pages re-render on the next frame; the old textures are simply released.
+    pub fn invalidate_content(&mut self) {
+        self.pages.clear();
+        self.thumbs.clear();
+        self.tiles.clear();
+        self.texts.clear();
+        self.text_failed.clear();
+        self.errors.clear();
+        self.waiting_since.clear();
+        self.last_queue.clear();
+        self.selection = None;
+        if let Some(f) = self.find.as_mut() {
+            f.matches.clear();
+            f.current = None;
+        }
+    }
+
+    /// Open the find bar (or focus it if already open).
+    pub fn open_find(&mut self) {
+        let f = self.find.get_or_insert_with(Find::default);
+        f.focus = true;
+    }
+
+    /// Re-run the search on all pages whose text is known (after the query changed).
+    pub fn rerun_find(&mut self) {
+        let Some(f) = self.find.as_mut() else { return };
+        f.case_query = f.query.clone();
+        f.matches.clear();
+        f.current = None;
+        let pages: Vec = self.texts.keys().copied().collect();
+        for p in pages {
+            self.refresh_find_page(p);
+        }
+    }
+
+    fn refresh_find_page(&mut self, page: usize) {
+        let (Some(f), Some(text)) = (self.find.as_mut(), self.texts.get(&page)) else { return };
+        if f.case_query.trim().is_empty() {
+            return;
+        }
+        let current_key = f.current.and_then(|c| f.matches.get(c).cloned());
+        f.matches.retain(|(p, _)| *p != page);
+        f.matches.extend(text.find(&f.case_query).into_iter().map(|r| (page, r)));
+        f.matches.sort_by_key(|(p, r)| (*p, r.start));
+        f.current = match current_key {
+            Some(k) => f.matches.iter().position(|m| *m == k),
+            None => None,
+        };
+        if f.current.is_none() && !f.matches.is_empty() {
+            // First match at or after the page being viewed.
+            let cur = self.current;
+            let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0);
+            f.current = Some(i);
+            let (p, _) = f.matches[i];
+            self.flash_match(p);
+        }
+    }
+
+    /// Move to the next/previous match.
+    pub fn find_step(&mut self, forward: bool) {
+        let Some(f) = self.find.as_mut() else { return };
+        if f.matches.is_empty() {
+            return;
+        }
+        let n = f.matches.len();
+        let c = f.current.unwrap_or(0);
+        let next = if forward { (c + 1) % n } else { (c + n - 1) % n };
+        f.current = Some(next);
+        let p = f.matches[next].0;
+        self.flash_match(p);
+    }
+
+    fn flash_match(&mut self, page: usize) {
+        let frac = self
+            .find
+            .as_ref()
+            .and_then(|f| f.current.and_then(|c| f.matches.get(c)))
+            .and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1]))
+            .zip(self.page_heights.get(page).copied())
+            .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0))
+            .unwrap_or(0.0);
+        self.goto = Some((page, frac));
+        self.current = page;
+    }
+
+    /// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its
+    /// text layer is loaded (used by UI tests and automation to aim pointer input).
+    pub fn glyph_screen_pos(&self, page: usize, glyph: usize) -> Option {
+        let (_, xf) = self.screen_xforms.iter().find(|(p, _)| *p == page)?;
+        let g = self.texts.get(&page)?.glyphs.get(glyph)?;
+        Some(xf.view_rect(g.rect).center())
+    }
+
+    /// Selected text, if any (⌘C).
+    pub fn selected_text(&self) -> Option {
+        let s = self.selection?;
+        let t = self.texts.get(&s.page)?;
+        Some(t.text_of(s.range())).filter(|x| !x.is_empty())
+    }
+
     pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
         self.thumbs.get(&page)
     }
@@ -97,11 +310,36 @@ impl DocView {
         self.page_input = (page + 1).to_string();
     }
 
+    /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−).
+    pub fn rotate_view(&mut self, clockwise: bool) {
+        self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360;
+        self.goto = Some((self.current, 0.0));
+    }
+
+    /// Displayed page size in points for this view rotation.
+    fn display_size(&self, p: &printcraft_render::PageInfo) -> (f32, f32) {
+        if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) }
+    }
+
+    /// Zoom keeping the centre of the view still.
     pub fn set_zoom(&mut self, zoom: f32) {
-        let frac = 0.0;
+        let centre = self.viewport_screen.center();
+        self.zoom_at(zoom, centre);
+    }
+
+    /// Zoom keeping the document point under `screen_pos` still (pinch, ⌘-scroll).
+    pub fn zoom_at(&mut self, zoom: f32, screen_pos: Pos2) {
+        let anchor = self.screen_rects.iter().find(|(_, r)| r.expand(GAP).contains(screen_pos)).map(|(p, r)| {
+            let f = (screen_pos - r.min) / r.size();
+            (*p, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), screen_pos - self.viewport_screen.min)
+        });
         self.zoom = zoom.clamp(0.08, 64.0);
         self.fit = Fit::None;
-        self.goto = Some((self.current, frac));
+        match anchor {
+            Some(a) => self.zoom_anchor = Some(a),
+            // Nothing on screen yet (e.g. a launch option): align the current page instead.
+            None => self.goto = Some((self.current, 0.0)),
+        }
     }
 
     /// Standard zoom steps (as in Acrobat's zoom menu).
@@ -117,16 +355,46 @@ impl DocView {
     /// Pull finished renders into textures.
     pub fn receive(&mut self, ctx: &egui::Context, pool: &RenderPool) {
         let mut got = false;
-        while let Some(r) = pool.try_recv() {
+        // Inline (single-threaded, e.g. web) rendering happens inside try_recv: one page per frame.
+        let budget = if pool.is_inline() { 1 } else { usize::MAX };
+        let mut n = 0;
+        while n < budget
+            && let Some(r) = pool.try_recv()
+        {
+            n += 1;
             got = true;
+            if r.request.kind == RequestKind::Text {
+                match r.text {
+                    Some(t) => {
+                        self.texts.insert(r.request.page, t);
+                        self.refresh_find_page(r.request.page);
+                    }
+                    None => {
+                        log::warn!("page {} text: {}", r.request.page + 1, r.error.unwrap_or_default());
+                        self.text_failed.insert(r.request.page);
+                    }
+                }
+                continue;
+            }
+            if let Some(e) = r.error {
+                log::warn!("page {}: {e}", r.request.page + 1);
+                self.errors.insert(r.request.page, e);
+                continue;
+            }
             let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.rgba);
             let page = r.request.page;
+            if let Some(t) = r.request.tile {
+                let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
+                self.tiles.insert((page, t.x / TILE, t.y / TILE), (r.request.tag, tex));
+                continue;
+            }
             if r.request.tag & THUMB_TAG != 0 {
                 let tex = ctx.load_texture(format!("thumb-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
                 self.thumbs.insert(page, tex);
             } else {
                 let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
                 self.pages.insert(page, PageTex { tag: r.request.tag, tex });
+                self.waiting_since.remove(&page);
             }
         }
         if got {
@@ -135,15 +403,15 @@ impl DocView {
     }
 
     fn fit_zoom(&mut self, info: &DocInfo) {
-        let max_w = info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
+        let max_w = info.pages.iter().map(|p| self.display_size(p).0).fold(1.0, f32::max);
         let avail_w = (self.viewport_w - 2.0 * SIDE).max(100.0);
         let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
         match self.fit {
             Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (max_w * PT * per_row),
             Fit::Page => {
-                let p = &info.pages[self.current.min(info.pages.len() - 1)];
-                let zw = (avail_w - GAP * (per_row - 1.0)) / (p.width * PT * per_row);
-                let zh = (self.viewport_h - 2.0 * MARGIN) / (p.height * PT);
+                let (w, h) = self.display_size(&info.pages[self.current.min(info.pages.len() - 1)]);
+                let zw = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row);
+                let zh = (self.viewport_h - 2.0 * MARGIN) / (h * PT);
                 self.zoom = zw.min(zh);
             }
             Fit::None => {}
@@ -159,14 +427,15 @@ impl DocView {
         match self.layout {
             PageLayout::Continuous | PageLayout::Single => {
                 for p in &info.pages {
-                    let size = vec2(p.width * s, p.height * s);
+                    let (w, h) = self.display_size(p);
+                    let size = vec2(w * s, h * s);
                     rects.push(Rect::from_min_size(pos2(((content_w - size.x) / 2.0).max(SIDE), y), size));
                     y += size.y + GAP;
                 }
             }
             PageLayout::TwoUp => {
                 for pair in info.pages.chunks(2) {
-                    let sizes: Vec = pair.iter().map(|p| vec2(p.width * s, p.height * s)).collect();
+                    let sizes: Vec = pair.iter().map(|p| self.display_size(p)).map(|(w, h)| vec2(w * s, h * s)).collect();
                     let row_w: f32 = sizes.iter().map(|v| v.x).sum::() + GAP * (sizes.len() as f32 - 1.0);
                     let row_h = sizes.iter().map(|v| v.y).fold(0.0, f32::max);
                     let mut x = ((content_w - row_w) / 2.0).max(SIDE);
@@ -187,32 +456,103 @@ impl DocView {
     }
 }
 
-/// Map a user-space rect on a page to screen space, honouring crop box and rotation.
-pub fn page_to_screen(info: &DocInfo, page: usize, page_rect: Rect, r: [f32; 4]) -> Rect {
-    let p = &info.pages[page];
-    let [cx0, cy0, cx1, cy1] = p.crop;
-    let (cw, ch) = ((cx1 - cx0).max(1.0), (cy1 - cy0).max(1.0));
-    let map = |x: f32, y: f32| -> Pos2 {
-        // Normalised coordinates in the unrotated crop box, y down.
-        let (u, v) = ((x - cx0) / cw, (cy1 - y) / ch);
-        let (u, v) = match p.rotation {
+/// Maps between a page's coordinate spaces and the screen, including view rotation.
+///
+/// *View space* is the page as rendered (points, y down, the document's own `/Rotate` applied);
+/// normalised page coordinates `(u, v)` are view space divided by the page size. The view
+/// rotation (View ▸ Rotate View) turns the page clockwise on screen by `rot` degrees.
+#[derive(Clone, Copy)]
+pub struct PageXform {
+    /// The page's rectangle on screen (already rotated, so width/height may be swapped).
+    pub rect: Rect,
+    pub rot: u16,
+    /// Page size in view space (unrotated by the view).
+    pub pw: f32,
+    pub ph: f32,
+}
+
+impl PageXform {
+    pub fn norm_to_screen(&self, u: f32, v: f32) -> Pos2 {
+        let (a, b) = match self.rot {
             90 => (1.0 - v, u),
             180 => (1.0 - u, 1.0 - v),
             270 => (v, 1.0 - u),
             _ => (u, v),
         };
-        pos2(page_rect.left() + u * page_rect.width(), page_rect.top() + v * page_rect.height())
-    };
-    Rect::from_two_pos(map(r[0], r[1]), map(r[2], r[3]))
+        pos2(self.rect.left() + a * self.rect.width(), self.rect.top() + b * self.rect.height())
+    }
+
+    pub fn screen_to_norm(&self, p: Pos2) -> (f32, f32) {
+        let (a, b) = ((p.x - self.rect.left()) / self.rect.width().max(1e-3), (p.y - self.rect.top()) / self.rect.height().max(1e-3));
+        match self.rot {
+            90 => (b, 1.0 - a),
+            180 => (1.0 - a, 1.0 - b),
+            270 => (1.0 - b, a),
+            _ => (a, b),
+        }
+    }
+
+    /// Screen point → view space (points).
+    pub fn screen_to_view(&self, p: Pos2) -> (f32, f32) {
+        let (u, v) = self.screen_to_norm(p);
+        (u * self.pw, v * self.ph)
+    }
+
+    /// A view-space rect [x0, y0, x1, y1] → screen rect.
+    pub fn view_rect(&self, g: [f32; 4]) -> Rect {
+        Rect::from_two_pos(self.norm_to_screen(g[0] / self.pw, g[1] / self.ph), self.norm_to_screen(g[2] / self.pw, g[3] / self.ph))
+    }
+
+    /// A PDF user-space rect (crop box, document `/Rotate`) → screen rect.
+    pub fn user_rect(&self, info: &DocInfo, page: usize, r: [f32; 4]) -> Rect {
+        let p = &info.pages[page];
+        let [cx0, cy0, cx1, cy1] = p.crop;
+        let (cw, ch) = ((cx1 - cx0).max(1.0), (cy1 - cy0).max(1.0));
+        let norm = |x: f32, y: f32| -> (f32, f32) {
+            let (u, v) = ((x - cx0) / cw, (cy1 - y) / ch);
+            match p.rotation {
+                90 => (1.0 - v, u),
+                180 => (1.0 - u, 1.0 - v),
+                270 => (v, 1.0 - u),
+                _ => (u, v),
+            }
+        };
+        let (a, b) = (norm(r[0], r[1]), norm(r[2], r[3]));
+        Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1))
+    }
+
+    /// Draw a texture covering the normalised page region, rotated with the view.
+    pub fn paint_image(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
+        let mut mesh = egui::Mesh::with_texture(tex);
+        let corners = [(u0, v0, 0.0, 0.0), (u1, v0, 1.0, 0.0), (u1, v1, 1.0, 1.0), (u0, v1, 0.0, 1.0)];
+        for (u, v, tu, tv) in corners {
+            mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
+        }
+        mesh.add_triangle(0, 1, 2);
+        mesh.add_triangle(0, 2, 3);
+        painter.add(egui::Shape::mesh(mesh));
+    }
 }
 
 pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
     use egui::{Key, KeyboardShortcut, Modifiers};
+    if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) {
+        view.open_find();
+    }
     if ctx.egui_wants_keyboard_input() {
         return;
     }
     let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k);
     let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
+    // Rotation first: egui matches ⌘+ loosely with respect to Shift.
+    if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus))
+        || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals))
+    {
+        view.rotate_view(true);
+    }
+    if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) {
+        view.rotate_view(false);
+    }
     if pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)) {
         view.zoom_step(true);
     }
@@ -230,7 +570,25 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
         view.fit = Fit::Width;
         view.goto = Some((view.current, 0.0));
     }
+    if pressed(cmd(Key::G)) {
+        view.find_step(true);
+    }
+    if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) {
+        view.find_step(false);
+    }
+    // ⌘C arrives as a Copy event on most platforms.
+    let copy = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy)));
+    if copy && let Some(text) = view.selected_text() {
+        ctx.copy_text(text);
+    }
     let key = |k| ctx.input(|i| i.key_pressed(k));
+    if key(Key::Escape) {
+        if view.selection.is_some() {
+            view.selection = None;
+        } else {
+            view.find = None;
+        }
+    }
     if key(Key::Home) {
         view.go_to_page(0);
     }
@@ -259,7 +617,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
     let view = &mut app.views[index];
     notices(view, info, ui, &t);
     if view.organize {
-        organize_grid(view, info, &doc.renderer, ui, &t);
+        organize_grid(view, info, &doc.renderer, doc.editable(), ui, &t);
         return;
     }
 
@@ -272,10 +630,17 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
     let zoom_delta = ui.input(|i| i.zoom_delta());
     if (zoom_delta - 1.0).abs() > 0.001 && ui.rect_contains_pointer(avail) {
         let z = view.zoom * zoom_delta;
-        view.set_zoom(z);
+        match ui.input(|i| i.pointer.hover_pos()) {
+            Some(p) => view.zoom_at(z, p),
+            None => view.set_zoom(z),
+        }
     }
+    view.viewport_screen = avail;
 
-    let max_w = info.pages.iter().map(|p| p.width).fold(0.0, f32::max) * view.zoom * PT * if view.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
+    let max_w = info.pages.iter().map(|p| view.display_size(p).0).fold(0.0, f32::max)
+        * view.zoom
+        * PT
+        * if view.layout == PageLayout::TwoUp { 2.0 } else { 1.0 };
     let content_w = (max_w + 2.0 * SIDE).max(avail.width());
     let rects = view.layout(info, content_w);
     let visible_pages: Vec = match view.layout {
@@ -294,7 +659,11 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
         drag: if app.quick_tool == QuickTool::Hand { egui::scroll_area::DragScroll::Always } else { egui::scroll_area::DragScroll::OnTouch },
         ..Default::default()
     });
-    if let Some((page, frac)) = view.goto.take() {
+    if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
+        let r = rects[page.min(rects.len() - 1)];
+        let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
+        scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
+    } else if let Some((page, frac)) = view.goto.take() {
         let page = page.min(rects.len() - 1);
         let r = rects[page];
         scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0));
@@ -307,11 +676,14 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
     let mut clicked_link: Option = None;
 
     let out = scroll.show_viewport(ui, |ui, viewport| {
-        let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click());
+        let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
         let origin = resp_rect.min - vec2(0.0, y_shift);
         let painter = ui.painter();
         let visible = viewport.translate(resp_rect.min.to_vec2());
         let mut wanted = Vec::new();
+        let mut visible_now = Vec::new();
+        view.screen_rects.clear();
+        view.screen_xforms.clear();
         let mut current = view.current;
         let mut best_overlap = -1.0f32;
         let pointer = ui.input(|i| i.pointer.hover_pos());
@@ -320,33 +692,163 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
             if !r.intersects(visible.expand(400.0)) {
                 continue;
             }
+            if r.intersects(visible) {
+                visible_now.push(i);
+                view.screen_rects.push((i, r));
+                view.screen_xforms
+                    .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }));
+            }
             let overlap = r.intersect(visible).height();
             if overlap > best_overlap {
                 best_overlap = overlap;
                 current = i;
             }
-            // Shadow + paper.
-            painter.rect_filled(r.translate(vec2(0.0, 2.0)).expand(1.5), CornerRadius::same(2), t.page_shadow);
+            let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) };
+            // Soft shadow + paper.
+            painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO));
             painter.rect_filled(r, CornerRadius::ZERO, Color32::WHITE);
-            match view.pages.get(&i) {
-                Some(p) => {
-                    painter.image(p.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
-                    if p.tag != tag {
-                        wanted.push(i);
+            if let Some(err) = view.errors.get(&i) {
+                painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4));
+                icons::paint(
+                    ui,
+                    Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)),
+                    "triangle-alert",
+                    26.0,
+                    Color32::from_rgb(0xC8, 0x3A, 0x3A),
+                );
+                let msg = ui.fonts_mut(|f| {
+                    f.layout(
+                        format!("This page couldn't be displayed.\n{err}"),
+                        theme::regular(12.5),
+                        Color32::from_rgb(0x6A, 0x2A, 0x2A),
+                        (r.width() - 40.0).max(80.0),
+                    )
+                });
+                painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK);
+            } else {
+                let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0));
+                let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD;
+                // Whole-page raster: sharp when small, a low-res backdrop when tiled.
+                let (want_scale, want_tag) = if tiled {
+                    let bs = BASE_SIDE / pw_pt.max(ph_pt);
+                    (bs, (bs * 1000.0) as u64)
+                } else {
+                    (scale, tag)
+                };
+                match view.pages.get(&i) {
+                    Some(p) => {
+                        xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
+                        if p.tag != want_tag {
+                            wanted.push((i, want_scale, want_tag, None));
+                        }
+                    }
+                    None => {
+                        wanted.push((i, want_scale, want_tag, None));
+                        let now = ui.input(|inp| inp.time);
+                        let since = *view.waiting_since.entry(i).or_insert(now);
+                        let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" };
+                        painter.text(r.center(), Align2::CENTER_CENTER, msg, theme::regular(12.0), t.text_faint);
                     }
                 }
-                None => {
-                    wanted.push(i);
-                    painter.text(r.center(), Align2::CENTER_CENTER, "Rendering…", theme::regular(12.0), t.text_faint);
+                if tiled && r.intersects(visible) {
+                    // Device-pixel geometry of the scaled page, and the visible part of it
+                    // (found by mapping the visible screen corners back into the page).
+                    let (dw, dh) = ((pw_pt * scale).round() as u32, (ph_pt * scale).round() as u32);
+                    let vis = r.intersect(visible);
+                    let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c));
+                    let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0)));
+                    let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1)));
+                    let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32);
+                    let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh));
+                    for ty in vy0 / TILE..=(vy1.saturating_sub(1)) / TILE {
+                        for tx in vx0 / TILE..=(vx1.saturating_sub(1)) / TILE {
+                            let (x, y) = (tx * TILE, ty * TILE);
+                            let (w, h) = (TILE.min(dw.saturating_sub(x)), TILE.min(dh.saturating_sub(y)));
+                            if w == 0 || h == 0 {
+                                continue;
+                            }
+                            match view.tiles.get(&(i, tx, ty)) {
+                                Some((ttag, tex)) if *ttag == tag => {
+                                    let (fw, fh) = (dw as f32, dh as f32);
+                                    xf.paint_image(painter, tex.id(), x as f32 / fw, y as f32 / fh, (x + w) as f32 / fw, (y + h) as f32 / fh);
+                                }
+                                _ => wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))),
+                            }
+                        }
+                    }
+                }
+            }
+            painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside);
+
+            // Text layer: find matches, selection, I-beam and drag-to-select.
+            let to_screen = |g: [f32; 4]| xf.view_rect(g);
+            if let Some(text) = view.texts.get(&i).cloned() {
+                if let Some(f) = &view.find {
+                    for (k, (mp, range)) in f.matches.iter().enumerate() {
+                        if *mp != i {
+                            continue;
+                        }
+                        let current = f.current == Some(k);
+                        for lr in text.line_rects(range.clone()) {
+                            let fill = if current {
+                                Color32::from_rgba_unmultiplied(255, 140, 0, 110)
+                            } else {
+                                Color32::from_rgba_unmultiplied(255, 214, 0, 90)
+                            };
+                            painter.rect_filled(to_screen(lr).expand(1.0), CornerRadius::same(2), fill);
+                        }
+                    }
+                }
+                if let Some(sel) = view.selection.filter(|s| s.page == i) {
+                    for lr in text.line_rects(sel.range()) {
+                        painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
+                    }
+                }
+                if !hand && let Some(p) = pointer.filter(|p| r.contains(*p)) {
+                    let (vx, vy) = xf.screen_to_view(p);
+                    let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]);
+                    let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p));
+                    if over_text && !over_link {
+                        ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
+                    }
+                    let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o));
+                    if resp.drag_started() && press_here && !over_link {
+                        let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
+                        let (ox, oy) = xf.screen_to_view(origin);
+                        view.selection = text.nearest(ox, oy).map(|a| Selection { page: i, anchor: a, head: a });
+                    }
+                    if resp.dragged()
+                        && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i)
+                        && let Some(h) = text.nearest(vx, vy)
+                    {
+                        sel.head = h;
+                    }
+                    if resp.double_clicked() && over_text {
+                        if let Some(a) = text.nearest(vx, vy) {
+                            // Expand to the word: stop at inferred spaces, explicit spaces and line ends.
+                            let is_break = |k: usize| text.glyphs[k].text.trim().is_empty();
+                            let mut s0 = a;
+                            while s0 > 0 && !text.space_before[s0] && text.line_of[s0 - 1] == text.line_of[s0] && !is_break(s0 - 1) {
+                                s0 -= 1;
+                            }
+                            let mut e = a;
+                            while e + 1 < text.glyphs.len() && !text.space_before[e + 1] && text.line_of[e + 1] == text.line_of[e] && !is_break(e + 1)
+                            {
+                                e += 1;
+                            }
+                            view.selection = Some(Selection { page: i, anchor: s0, head: e });
+                        }
+                    } else if resp.clicked() && !over_link {
+                        view.selection = None;
+                    }
                 }
             }
-            painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
 
             // Form-field highlight (Acrobat's "Highlight existing fields").
             if view.highlight_fields {
                 for f in info.fields.iter().filter(|f| f.page == Some(i)) {
                     if let Some(fr) = f.rect {
-                        let sr = page_to_screen(info, i, r, fr);
+                        let sr = xf.user_rect(info, i, fr);
                         painter.rect_filled(sr, CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48));
                         painter.rect_stroke(
                             sr,
@@ -360,7 +862,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
             // Link hover + click.
             if let Some(p) = pointer {
                 for l in info.links.iter().filter(|l| l.page == i) {
-                    let sr = page_to_screen(info, i, r, l.rect);
+                    let sr = xf.user_rect(info, i, l.rect);
                     if sr.contains(p) {
                         ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
                         let label = match &l.target {
@@ -376,7 +878,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
                 }
                 // Annotation hover shows the comment, as Acrobat's popups do.
                 for a in info.annotations.iter().filter(|a| a.page == i) {
-                    let sr = page_to_screen(info, i, r, a.rect);
+                    let sr = xf.user_rect(info, i, a.rect);
                     if sr.contains(p) && hover_text.is_none() {
                         let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
                         let body = a.contents.clone().unwrap_or_default();
@@ -393,7 +895,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
                 let age = (now - t0) as f32;
                 if age < 1.6 {
                     let a = ((1.6 - age) / 1.6 * 255.0) as u8;
-                    let sr = page_to_screen(info, i, r, fr).expand(4.0);
+                    let sr = xf.user_rect(info, i, fr).expand(4.0);
                     painter.rect_stroke(
                         sr,
                         CornerRadius::same(3),
@@ -412,19 +914,37 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
                 view.page_input = (current + 1).to_string();
             }
         }
-        wanted
+        (wanted, visible_now)
     });
 
+    // Bound texture memory: keep sharp rasters only near the current page.
+    if view.pages.len() > 24 {
+        let cur = view.current;
+        view.pages.retain(|&p, _| p.abs_diff(cur) <= 8);
+    }
     // Schedule renders: visible pages first (nearest the current page), then thumbnails.
-    let mut wanted = out.inner;
+    let (mut wanted, visible_now) = out.inner;
     let cur = view.current;
-    wanted.sort_by_key(|&p| (p as isize - cur as isize).unsigned_abs());
-    let mut queue: Vec = wanted.into_iter().map(|page| RenderRequest { page, scale, tag }).collect();
+    // Nearest pages first; for each page, the backdrop before its tiles.
+    wanted.sort_by_key(|w| ((w.0 as isize - cur as isize).unsigned_abs(), w.3.is_some()));
+    // Tiles for other zoom levels or far-away pages are useless: free them.
+    view.tiles.retain(|(p, _, _), (t, _)| *t == tag && p.abs_diff(cur) <= 2);
+    let mut queue: Vec =
+        wanted.iter().map(|&(page, scale, tag, tile)| RenderRequest { page, kind: RequestKind::Pixels, tile, scale, tag }).collect();
+    // Text layers: visible pages for selection, every page while a search is active.
+    let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p);
+    let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() };
+    if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) {
+        let n = info.pages.len();
+        let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect();
+        text_pages.extend(rest);
+    }
+    queue.extend(text_pages.into_iter().map(|page| RenderRequest { page, kind: RequestKind::Text, tile: None, scale: 1.0, tag: TEXT_TAG }));
     if want_thumbs {
         let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
         for page in 0..info.pages.len() {
-            if !view.thumbs.contains_key(&page) {
-                queue.push(RenderRequest { page, scale: s, tag: THUMB_TAG });
+            if !view.thumbs.contains_key(&page) && !view.errors.contains_key(&page) {
+                queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG });
             }
         }
     }
@@ -444,6 +964,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
             });
         });
     }
+    find_bar(view, info.pages.len(), avail, ui, &t);
     match clicked_link {
         Some(LinkTarget::Page(p)) => view.go_to_page(p),
         Some(LinkTarget::Uri(u)) => ui.ctx().open_url(egui::OpenUrl::new_tab(u)),
@@ -453,6 +974,76 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
     quick_bar(app, avail, ui);
 }
 
+/// Acrobat-style floating find bar at the top-right of the document area.
+fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) {
+    let Some(find) = view.find.as_mut() else { return };
+    let mut close = false;
+    let mut step: Option = None;
+    let searched = view.texts.len() + view.text_failed.len();
+    egui::Area::new(egui::Id::new("find-bar"))
+        .order(egui::Order::Middle)
+        .pivot(Align2::RIGHT_TOP)
+        .fixed_pos(area.right_top() + vec2(-18.0, 12.0))
+        .show(ui.ctx(), |ui| {
+            egui::Frame::NONE
+                .fill(t.card)
+                .stroke(Stroke::new(1.0, t.border))
+                .corner_radius(CornerRadius::same(10))
+                .shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 90 } else { 30 }) })
+                .inner_margin(egui::Margin::symmetric(10, 6))
+                .show(ui, |ui| {
+                    ui.horizontal(|ui| {
+                        ui.add(icons::image("search", 16.0, t.text_muted));
+                        let edit = egui::TextEdit::singleline(&mut find.query)
+                            .id(egui::Id::new("find-input"))
+                            .hint_text("Find text")
+                            .desired_width(220.0)
+                            .frame(egui::Frame::NONE);
+                        let r = ui.add(edit);
+                        if find.focus {
+                            r.request_focus();
+                            find.focus = false;
+                        }
+                        if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+                            step = Some(!ui.input(|i| i.modifiers.shift));
+                            r.request_focus();
+                        }
+                        if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+                            close = true;
+                        }
+                        let status = if find.query.trim().is_empty() {
+                            String::new()
+                        } else if find.matches.is_empty() {
+                            if searched < pages { format!("Searching… {searched}/{pages}") } else { "No matches".into() }
+                        } else {
+                            let more = if searched < pages { "+" } else { "" };
+                            format!("{} of {}{more}", find.current.map(|c| c + 1).unwrap_or(0), find.matches.len())
+                        };
+                        ui.label(egui::RichText::new(status).font(theme::regular(12.0)).color(t.text_muted));
+                        if icons::button(ui, "chevron-up", 26.0, false, "Previous (⇧⌘G)").clicked() {
+                            step = Some(false);
+                        }
+                        if icons::button(ui, "chevron-down", 26.0, false, "Next (⌘G)").clicked() {
+                            step = Some(true);
+                        }
+                        if icons::button(ui, "x", 26.0, false, "Close (Esc)").clicked() {
+                            close = true;
+                        }
+                    });
+                });
+        });
+    if close {
+        view.find = None;
+        return;
+    }
+    if find.query != find.case_query {
+        view.rerun_find();
+    }
+    if let Some(forward) = step {
+        view.find_step(forward);
+    }
+}
+
 fn notices(view: &mut DocView, info: &DocInfo, ui: &mut egui::Ui, t: &Tokens) {
     if view.notice_dismissed {
         return;
@@ -519,11 +1110,80 @@ fn quick_bar(app: &mut PrintCraftApp, area: Rect, ui: &mut egui::Ui) {
     });
 }
 
-/// Organize pages: a thumbnail grid (Acrobat's Organize Pages view).
-fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, ui: &mut egui::Ui, t: &Tokens) {
+/// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar).
+fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut egui::Ui, t: &Tokens) {
+    let targets = view.target_pages();
+    let n = info.pages.len();
+    let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0));
+    egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(16, 8)).show(ui, |ui| {
+        ui.horizontal(|ui| {
+            ui.spacing_mut().item_spacing.x = 4.0;
+            let label = match view.selected.len() {
+                0 => format!("Page {} of {n}", view.current + 1),
+                1 => "1 page selected".to_string(),
+                k => format!("{k} pages selected"),
+            };
+            // Fixed width so the buttons never shift as the selection text changes.
+            ui.add_sized([150.0, 30.0], egui::Label::new(egui::RichText::new(label).font(theme::medium(13.0)).color(t.text_muted)).truncate());
+            ui.add_enabled_ui(editable, |ui| {
+                if icons::button(ui, "rotate-ccw", 30.0, false, "Rotate counterclockwise").clicked() {
+                    view.pending_edit = Some(Edit::RotatePages { pages: targets.clone(), degrees: -90 });
+                }
+                if icons::button(ui, "rotate-cw", 30.0, false, "Rotate clockwise").clicked() {
+                    view.pending_edit = Some(Edit::RotatePages { pages: targets.clone(), degrees: 90 });
+                }
+                let can_delete = targets.len() < n;
+                if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash-2", 30.0, false, "Delete pages (Delete)")).inner.clicked() {
+                    view.pending_edit = Some(Edit::DeletePages { pages: targets.clone() });
+                }
+                if icons::button(ui, "file-plus", 30.0, false, "Insert a blank page after the selection").clicked() {
+                    let c = info.pages[last].crop;
+                    let (w, h) = ((c[2] - c[0]).abs().max(1.0) as f64, (c[3] - c[1]).abs().max(1.0) as f64);
+                    view.pending_edit = Some(Edit::InsertBlankPage { at: last + 1, width: w, height: h });
+                }
+                if ui.add_enabled_ui(first > 0, |ui| icons::button(ui, "chevron-left", 30.0, false, "Move earlier")).inner.clicked() {
+                    view.pending_edit = Some(Edit::MovePages { pages: targets.clone(), to: first - 1 });
+                }
+                if ui.add_enabled_ui(last + 1 < n, |ui| icons::button(ui, "chevron-right", 30.0, false, "Move later")).inner.clicked() {
+                    view.pending_edit = Some(Edit::MovePages { pages: targets.clone(), to: first + 1 });
+                }
+            });
+            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                if widgets::ghost_button(ui, "x", "Close").on_hover_text("Back to the document").clicked() {
+                    view.organize = false;
+                }
+            });
+        });
+    });
+    // Keys act on the selection unless a text field has focus.
+    if editable && !ui.ctx().egui_wants_keyboard_input() {
+        use egui::{Key, KeyboardShortcut, Modifiers};
+        let (del, all, esc) = ui.input_mut(|i| {
+            (
+                i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace),
+                i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::A)),
+                i.consume_key(Modifiers::NONE, Key::Escape),
+            )
+        });
+        if del && targets.len() < n {
+            view.pending_edit = Some(Edit::DeletePages { pages: targets });
+        }
+        if all {
+            view.selected = (0..n).collect();
+        }
+        if esc {
+            view.selected.clear();
+        }
+    }
+}
+
+/// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click
+/// toggles, ⇧-click extends; double-click opens the page.
+fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, ui: &mut egui::Ui, t: &Tokens) {
     let ppp = ui.ctx().pixels_per_point();
     let cell = vec2(190.0, 250.0);
     let mut open_page = None;
+    organize_toolbar(view, info, editable, ui, t);
     egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
         ui.add_space(20.0);
         let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize;
@@ -536,13 +1196,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, ui: &mut
                 let Some(p) = info.pages.get(i) else { break };
                 let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell);
                 let resp = ui.interact(c, ui.id().with(("org", i)), Sense::click());
+                resp.widget_info(|| {
+                    egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), format!("Page {}", p.label))
+                });
                 let s = (cell.x - 44.0) / p.width.max(1.0);
                 let size = vec2(p.width * s, p.height * s).min(vec2(cell.x - 44.0, cell.y - 56.0));
                 let pr = Rect::from_center_size(pos2(c.center().x, c.top() + 16.0 + size.y / 2.0), size);
-                let selected = i == view.current;
+                let selected = view.selected.contains(&i) || (view.selected.is_empty() && i == view.current);
                 if selected || resp.hovered() {
                     ui.painter().rect_filled(c.shrink(6.0), CornerRadius::same(8), if selected { t.accent_soft } else { t.hover });
                 }
+                if view.selected.contains(&i) {
+                    ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
+                }
                 ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
                 ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE);
                 if let Some(tex) = view.thumbs.get(&i) {
@@ -551,6 +1217,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, ui: &mut
                 ui.painter().rect_stroke(pr, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
                 ui.painter().text(pos2(c.center().x, pr.bottom() + 16.0), Align2::CENTER_CENTER, &p.label, theme::medium(12.0), t.text_muted);
                 if resp.clicked() {
+                    let m = ui.input(|i| i.modifiers);
+                    if m.shift {
+                        let a = view.select_anchor.unwrap_or(view.current);
+                        view.selected = (a.min(i)..=a.max(i)).collect();
+                    } else if m.command {
+                        if !view.selected.remove(&i) {
+                            view.selected.insert(i);
+                        }
+                        view.select_anchor = Some(i);
+                    } else {
+                        view.selected = [i].into();
+                        view.select_anchor = Some(i);
+                    }
                     view.current = i;
                 }
                 if resp.double_clicked() {
@@ -560,8 +1239,10 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, ui: &mut
         }
     });
     let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max);
-    let queue: Vec =
-        (0..info.pages.len()).filter(|p| !view.thumbs.contains_key(p)).map(|page| RenderRequest { page, scale: s, tag: THUMB_TAG }).collect();
+    let queue: Vec = (0..info.pages.len())
+        .filter(|p| !view.thumbs.contains_key(p) && !view.errors.contains_key(p))
+        .map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: s, tag: THUMB_TAG })
+        .collect();
     if queue != view.last_queue {
         pool.set_queue(queue.clone());
         view.last_queue = queue;
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index c58aab9f1..a1eb6eed4 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -30,13 +30,13 @@ pub fn tab_strip(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                 let mut close = None;
                 for i in 0..app.views.len() {
                     let Some(doc) = app.session.get(app.views[i].id) else { continue };
-                    let name = doc.name.clone();
-                    if tab(ui, &t, &name, app.active == Some(i), &mut close, i).clicked() {
+                    let (name, dirty) = (doc.name.clone(), doc.dirty);
+                    if tab(ui, &t, &name, dirty, app.active == Some(i), &mut close, i).clicked() {
                         app.active = Some(i);
                     }
                 }
                 if let Some(i) = close {
-                    app.close_tab(i);
+                    app.request_close_tab(i);
                 }
                 ui.add_space(4.0);
                 if widgets::ghost_button(ui, "plus", "Open").on_hover_text("Open a PDF (⌘O)").clicked() {
@@ -59,11 +59,13 @@ pub fn tab_strip(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
         });
 }
 
-fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, active: bool, close: &mut Option, index: usize) -> egui::Response {
+fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, close: &mut Option, index: usize) -> egui::Response {
     let font = theme::regular(13.0);
     let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::()) } else { name.to_string() };
     let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x);
     let (rect, resp) = ui.allocate_exact_size(vec2(text_w + 64.0, 30.0), Sense::click());
+    let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
+    resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, &a11y));
     let bg = if active {
         t.chrome
     } else if resp.hovered() {
@@ -85,13 +87,16 @@ fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, active: bool, close: &mut Opti
     if x.hovered() {
         ui.painter().rect_filled(x_rect, CornerRadius::same(4), t.pressed);
     }
-    if active || resp.hovered() || x.hovered() {
+    // Unsaved changes: a dot where the close button sits, until the tab is hovered.
+    if dirty && !resp.hovered() && !x.hovered() {
+        ui.painter().circle_filled(x_rect.center(), 4.0, if active { t.text } else { t.text_muted });
+    } else if active || resp.hovered() || x.hovered() {
         icons::paint(ui, x_rect, "x", 13.0, t.text_muted);
     }
     if x.clicked() {
         *close = Some(index);
     }
-    resp.on_hover_text(name)
+    resp.on_hover_text(if dirty { format!("{name} — unsaved changes") } else { name.to_string() })
 }
 
 pub fn mode_bar(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
@@ -156,13 +161,43 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
             if ui.add_enabled(has, egui::Button::new("Close file").shortcut_text("⌘W")).clicked()
                 && let Some(i) = app.active
             {
-                app.close_tab(i);
+                app.request_close_tab(i);
+            }
+            let dirty = app.active_ids().and_then(|(_, id)| app.session.get(id)).is_some_and(|d| d.dirty);
+            if ui.add_enabled(has && dirty, egui::Button::new("Save").shortcut_text("⌘S")).clicked() {
+                app.save_active(crate::SaveTarget::InPlace);
+            }
+            if ui.add_enabled(has, egui::Button::new("Save as…").shortcut_text("⇧⌘S")).clicked() {
+                app.save_active(crate::SaveTarget::As);
+            }
+            ui.separator();
+            if ui.add_enabled(has, egui::Button::new("Find…").shortcut_text("⌘F")).clicked()
+                && let Some(i) = app.active
+            {
+                app.views[i].open_find();
             }
             ui.separator();
             if ui.add_enabled(has, egui::Button::new("Document properties…").shortcut_text("⌘D")).clicked() {
                 app.dialog = Some(Dialog::Properties(PropsTab::Description));
             }
         });
+        ui.menu_button("Edit", |ui| {
+            let doc = app.active_ids().and_then(|(_, id)| app.session.get(id));
+            let undo = doc.and_then(|d| d.can_undo()).map(str::to_owned);
+            let redo = doc.and_then(|d| d.can_redo()).map(str::to_owned);
+            let label = |verb: &str, what: &Option| what.as_ref().map(|w| format!("{verb} {w}")).unwrap_or_else(|| verb.to_string());
+            if ui.add_enabled(undo.is_some(), egui::Button::new(label("Undo", &undo)).shortcut_text("⌘Z")).clicked() {
+                app.undo();
+            }
+            if ui.add_enabled(redo.is_some(), egui::Button::new(label("Redo", &redo)).shortcut_text("⇧⌘Z")).clicked() {
+                app.redo();
+            }
+            ui.separator();
+            let has = app.active.is_some();
+            if ui.add_enabled(has, egui::Button::new("Organize pages")).clicked() {
+                app.run_command("page.organize");
+            }
+        });
         ui.menu_button("View", |ui| {
             if let Some(i) = app.active {
                 let v = &mut app.views[i];
@@ -182,6 +217,12 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                 if widgets::menu_item(ui, "Zoom out", "⌘−").clicked() {
                     v.zoom_step(false);
                 }
+                if widgets::menu_item(ui, "Rotate view clockwise", "⇧⌘+").clicked() {
+                    v.rotate_view(true);
+                }
+                if widgets::menu_item(ui, "Rotate view counterclockwise", "⇧⌘−").clicked() {
+                    v.rotate_view(false);
+                }
                 ui.separator();
                 ui.label(egui::RichText::new("Page display").color(t.text_faint).small());
                 for (l, label) in
@@ -194,6 +235,10 @@ fn main_menu(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                 }
                 ui.separator();
             }
+            if app.active.is_some() && widgets::menu_item(ui, "Full screen mode", "⌘L").clicked() {
+                let ctx = ui.ctx().clone();
+                app.set_full_screen(&ctx, true);
+            }
             if widgets::menu_item(ui, "Read mode", "⌃⌘H").clicked() {
                 app.mode = if app.mode == Mode::Read { Mode::AllTools } else { Mode::Read };
             }
@@ -278,6 +323,9 @@ pub fn right_rail(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                 if icons::button(ui, "zoom-in", 32.0, false, "Zoom in (⌘+)").clicked() {
                     view.zoom_step(true);
                 }
+                if icons::button(ui, "rotate-cw", 32.0, false, "Rotate view clockwise (⇧⌘+)").clicked() {
+                    view.rotate_view(true);
+                }
                 let fit_icon = if view.fit == Fit::Width { "maximize" } else { "columns-2" };
                 if icons::button(ui, fit_icon, 32.0, false, "Toggle fit page / fit width").clicked() {
                     view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 90c3c7baf..19c8f110e 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1,12 +1,29 @@
 //! Modal dialogs: Document Properties, Keyboard Shortcuts, About.
 
-use egui::{Align, Layout, vec2};
+use egui::{Align, Layout};
 
 use crate::theme::{self, Tokens};
-use crate::{Dialog, PrintCraftApp, PropsTab, panels::human_size, widgets};
+use printcraft_engine::Edit;
+
+use crate::{CloseRequest, Dialog, PrintCraftApp, PropsTab, panels::human_size, widgets};
+
+const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
 
 pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
-    let Some(dialog) = app.dialog else { return };
+    password(app, ctx);
+    save_prompt(app, ctx);
+    let Some(dialog) = app.dialog else {
+        app.props_draft = None;
+        return;
+    };
+    // Seed the editable Description fields from the document when the dialog opens.
+    if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
+        && app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
+        && let Some(doc) = app.session.get(id)
+    {
+        app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
+    }
+    let mut apply = false;
     let t = Tokens::get(ctx);
     let mut close = false;
     let mut next = dialog;
@@ -17,7 +34,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
                 ui.label(egui::RichText::new("Document Properties").font(theme::semibold(18.0)));
                 ui.add_space(8.0);
                 ui.horizontal(|ui| {
-                    for (tb, label) in [(PropsTab::Description, "Description"), (PropsTab::Security, "Security"), (PropsTab::Advanced, "Advanced")] {
+                    for (tb, label) in [
+                        (PropsTab::Description, "Description"),
+                        (PropsTab::Security, "Security"),
+                        (PropsTab::Fonts, "Fonts"),
+                        (PropsTab::Advanced, "Advanced"),
+                    ] {
                         if widgets::mode_tab(ui, label, tab == tb).clicked() {
                             next = Dialog::Properties(tb);
                         }
@@ -32,44 +54,76 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
                     ui.label(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) });
                     ui.end_row();
                 };
-                egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
-                    PropsTab::Description => {
-                        row(ui, "File", doc.name.clone());
-                        row(ui, "Title", i.title.clone().unwrap_or_default());
-                        row(ui, "Author", i.author.clone().unwrap_or_default());
-                        row(ui, "Subject", i.subject.clone().unwrap_or_default());
-                        row(ui, "Keywords", i.keywords.clone().unwrap_or_default());
-                        row(ui, "Application", i.creator.clone().unwrap_or_default());
-                        row(ui, "PDF producer", i.producer.clone().unwrap_or_default());
-                    }
-                    PropsTab::Security => {
-                        row(ui, "Security method", if i.encrypted { "Password security".into() } else { "No security".into() });
-                        for k in [
-                            "Printing",
-                            "Changing the document",
-                            "Document assembly",
-                            "Content copying",
-                            "Commenting",
-                            "Filling of form fields",
-                            "Signing",
-                        ] {
-                            row(ui, k, if i.encrypted { "See permissions (M8)".into() } else { "Allowed".into() });
+                egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
+                    egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
+                        PropsTab::Description => {
+                            row(ui, "File", doc.name.clone());
+                            match app.props_draft.as_mut() {
+                                Some((_, draft)) if doc.editable() => {
+                                    for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
+                                        let l = ui.label(egui::RichText::new(*k).color(t.text_muted));
+                                        ui.add(egui::TextEdit::singleline(v).desired_width(f32::INFINITY).id_salt(("info", *k))).labelled_by(l.id);
+                                        ui.end_row();
+                                    }
+                                }
+                                _ => {
+                                    row(ui, "Title", i.title.clone().unwrap_or_default());
+                                    row(ui, "Author", i.author.clone().unwrap_or_default());
+                                    row(ui, "Subject", i.subject.clone().unwrap_or_default());
+                                    row(ui, "Keywords", i.keywords.clone().unwrap_or_default());
+                                }
+                            }
+                            row(ui, "Application", i.creator.clone().unwrap_or_default());
+                            row(ui, "PDF producer", i.producer.clone().unwrap_or_default());
                         }
-                    }
-                    PropsTab::Advanced => {
-                        row(ui, "PDF version", i.pdf_version.replace("Pdf", "").replace('_', "."));
-                        row(ui, "Location", doc.path.clone().unwrap_or_default());
-                        row(ui, "File size", format!("{} ({} bytes)", human_size(i.file_size), i.file_size));
-                        let p = &i.pages[0];
-                        row(ui, "Page size", format!("{:.2} × {:.2} in", p.width / 72.0, p.height / 72.0));
-                        row(ui, "Number of pages", i.pages.len().to_string());
-                        row(ui, "Tagged PDF", yes(i.tagged));
-                        row(ui, "Form fields", i.fields.len().to_string());
-                        row(ui, "Comments", i.annotations.len().to_string());
-                        row(ui, "Layers", i.layers.len().to_string());
-                        row(ui, "Attachments", i.attachments.len().to_string());
-                        row(ui, "JavaScript", yes(i.has_javascript));
-                    }
+                        PropsTab::Security => {
+                            row(ui, "Security method", if i.encrypted { "Password security".into() } else { "No security".into() });
+                            for k in [
+                                "Printing",
+                                "Changing the document",
+                                "Document assembly",
+                                "Content copying",
+                                "Commenting",
+                                "Filling of form fields",
+                                "Signing",
+                            ] {
+                                row(ui, k, if i.encrypted { "See permissions (M8)".into() } else { "Allowed".into() });
+                            }
+                        }
+                        PropsTab::Fonts => {
+                            if i.fonts.is_empty() {
+                                row(ui, "Fonts", "No fonts are referenced by the pages.".into());
+                            }
+                            for f in &i.fonts {
+                                let mut detail = f.kind.clone();
+                                if let Some(e) = &f.encoding {
+                                    detail.push_str(&format!(" · {e}"));
+                                }
+                                detail.push_str(if f.subset {
+                                    " · Embedded subset"
+                                } else if f.embedded {
+                                    " · Embedded"
+                                } else {
+                                    " · Not embedded (substituted)"
+                                });
+                                row(ui, &f.name, detail);
+                            }
+                        }
+                        PropsTab::Advanced => {
+                            row(ui, "PDF version", i.pdf_version.replace("Pdf", "").replace('_', "."));
+                            row(ui, "Location", doc.path.clone().unwrap_or_default());
+                            row(ui, "File size", format!("{} ({} bytes)", human_size(i.file_size), i.file_size));
+                            let p = &i.pages[0];
+                            row(ui, "Page size", format!("{:.2} × {:.2} in", p.width / 72.0, p.height / 72.0));
+                            row(ui, "Number of pages", i.pages.len().to_string());
+                            row(ui, "Tagged PDF", yes(i.tagged));
+                            row(ui, "Form fields", i.fields.len().to_string());
+                            row(ui, "Comments", i.annotations.len().to_string());
+                            row(ui, "Layers", i.layers.len().to_string());
+                            row(ui, "Attachments", i.attachments.len().to_string());
+                            row(ui, "JavaScript", yes(i.has_javascript));
+                        }
+                    })
                 });
             }
             Dialog::Shortcuts => {
@@ -79,8 +133,16 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
                     for (k, v) in [
                         ("⌘O", "Open"),
                         ("⌘W", "Close file"),
+                        ("⌘S / ⇧⌘S", "Save / Save as"),
+                        ("⌘Z / ⇧⌘Z", "Undo / Redo"),
+                        ("Delete", "Delete selected pages (Organize)"),
                         ("⌘D", "Document properties"),
                         ("⌘K", "Find tools and commands"),
+                        ("⌘F", "Find text in the document"),
+                        ("⌘G / ⇧⌘G", "Next / previous match"),
+                        ("⌘C", "Copy selected text"),
+                        ("Double-click", "Select a word"),
+                        ("Esc", "Clear selection / close find"),
                         ("⌘1", "Actual size"),
                         ("⌘0", "Zoom to page level"),
                         ("⌘2", "Fit to width"),
@@ -108,20 +170,132 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
             }
         }
         ui.add_space(12.0);
+        let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
         ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
-            if widgets::pill_button(ui, "Close", true).clicked() {
+            if changed {
+                if widgets::pill_button(ui, "OK", true).clicked() {
+                    apply = true;
+                    close = true;
+                }
+                if widgets::pill_button(ui, "Cancel", false).clicked() {
+                    close = true;
+                }
+            } else if widgets::pill_button(ui, "Close", true).clicked() {
                 close = true;
             }
         });
-        let _ = vec2(0.0, 0.0);
     });
+    if apply && let Some(edits) = draft_changes(app) {
+        app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
+    }
     if modal.should_close() || close {
         app.dialog = None;
+        app.props_draft = None;
     } else {
         app.dialog = Some(next);
     }
 }
 
+/// Info edits needed to make the document match the Description draft.
+fn draft_changes(app: &PrintCraftApp) -> Option> {
+    let (id, draft) = app.props_draft.as_ref()?;
+    let doc = app.session.get(*id)?;
+    Some(
+        INFO_KEYS
+            .iter()
+            .zip(draft.iter())
+            .filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim())
+            .map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() })
+            .collect(),
+    )
+}
+
+/// "Save changes?" when closing a tab or quitting with unsaved edits.
+fn save_prompt(app: &mut PrintCraftApp, ctx: &egui::Context) {
+    let Some(req) = app.close_request else { return };
+    let index = match req {
+        CloseRequest::Tab(i) => Some(i),
+        CloseRequest::Quit => app.first_dirty(),
+    };
+    let Some(name) = index.and_then(|i| app.views.get(i)).and_then(|v| app.session.get(v.id)).map(|d| d.name.clone()) else {
+        // Nothing left to ask about (tab already gone or no dirty documents).
+        app.resolve_close(ctx, Some(false));
+        return;
+    };
+    let t = Tokens::get(ctx);
+    let mut choice: Option> = None;
+    let modal = egui::Modal::new(egui::Id::new("save_prompt")).show(ctx, |ui| {
+        ui.set_width(420.0);
+        ui.horizontal(|ui| {
+            ui.add(crate::icons::image("save", 22.0, t.accent));
+            ui.label(egui::RichText::new(format!("Save changes to “{name}” before closing?")).font(theme::semibold(16.0)));
+        });
+        ui.add_space(6.0);
+        ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_muted));
+        ui.add_space(14.0);
+        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+            if widgets::pill_button(ui, "Save", true).clicked() {
+                choice = Some(Some(true));
+            }
+            if widgets::pill_button(ui, "Cancel", false).clicked() {
+                choice = Some(None);
+            }
+            ui.add_space(24.0);
+            if widgets::pill_button(ui, "Don't save", false).clicked() {
+                choice = Some(Some(false));
+            }
+        });
+    });
+    if choice.is_none() && modal.should_close() {
+        choice = Some(None);
+    }
+    if let Some(c) = choice {
+        app.resolve_close(ctx, c);
+    }
+}
+
 fn yes(b: bool) -> String {
     if b { "Yes".into() } else { "No".into() }
 }
+
+/// Password prompt for encrypted documents (Acrobat: "Password" dialog on open).
+fn password(app: &mut PrintCraftApp, ctx: &egui::Context) {
+    let Some(prompt) = app.password_prompt.as_mut() else { return };
+    let t = Tokens::get(ctx);
+    let mut submit = false;
+    let mut cancel = false;
+    let modal = egui::Modal::new(egui::Id::new("password")).show(ctx, |ui| {
+        ui.set_width(400.0);
+        ui.horizontal(|ui| {
+            ui.add(crate::icons::image("lock", 22.0, t.accent));
+            ui.label(egui::RichText::new("Password required").font(theme::semibold(17.0)));
+        });
+        ui.add_space(6.0);
+        ui.label(format!("“{}” is protected. Enter a password to open it.", prompt.name));
+        ui.add_space(8.0);
+        let r = ui.add(egui::TextEdit::singleline(&mut prompt.input).password(true).hint_text("Password").desired_width(f32::INFINITY));
+        r.request_focus();
+        if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+            submit = true;
+        }
+        if let Some(e) = &prompt.error {
+            ui.add_space(4.0);
+            ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)));
+        }
+        ui.add_space(12.0);
+        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+            if widgets::pill_button(ui, "Open", true).clicked() {
+                submit = true;
+            }
+            if widgets::pill_button(ui, "Cancel", false).clicked() {
+                cancel = true;
+            }
+        });
+    });
+    if submit {
+        let pw = prompt.input.clone();
+        app.submit_password(Some(pw));
+    } else if cancel || modal.should_close() {
+        app.submit_password(None);
+    }
+}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
new file mode 100644
index 000000000..18c119ff4
--- /dev/null
+++ b/crates/ui-egui/src/editing.rs
@@ -0,0 +1,263 @@
+//! Editing glue: apply engine edits from the UI, undo/redo, save/save-as, and the
+//! "save changes?" prompt when closing a tab or quitting with unsaved edits.
+
+use printcraft_engine::Edit;
+
+use crate::PrintCraftApp;
+
+/// What the user was doing when we asked whether to save.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum CloseRequest {
+    /// Close this tab (index into `views`).
+    Tab(usize),
+    /// Quit the application once every dirty document is resolved.
+    Quit,
+}
+
+/// Where Save writes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SaveTarget {
+    /// The file's own path; asks for one if the document has none (e.g. opened from bytes).
+    InPlace,
+    /// Always ask for a new location.
+    As,
+}
+
+impl PrintCraftApp {
+    /// Apply an edit to the active document. Returns `true` on success; failures are shown.
+    pub fn apply_edit(&mut self, edit: Edit) -> bool {
+        let Some((i, id)) = self.active_ids() else { return false };
+        let label = edit.label();
+        match self.session.apply(id, edit.clone()) {
+            Ok(()) => {
+                let info = &self.session.get(id).expect("exists").info;
+                let view = &mut self.views[i];
+                view.document_changed(info);
+                // Keep the pages the user acted on selected, where they now are.
+                match edit {
+                    Edit::MovePages { pages, to } => {
+                        let n = pages.iter().collect::>().len();
+                        let to = to.min(info.pages.len() - n);
+                        view.select_pages(&(to..to + n).collect::>());
+                    }
+                    Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
+                    Edit::DeletePages { .. } => view.select_pages(&[]),
+                    _ => {}
+                }
+                true
+            }
+            Err(e) => {
+                self.notify(format!("{label} failed: {e}"));
+                false
+            }
+        }
+    }
+
+    pub fn undo(&mut self) {
+        self.history_step(true);
+    }
+
+    pub fn redo(&mut self) {
+        self.history_step(false);
+    }
+
+    fn history_step(&mut self, undo: bool) {
+        let Some((i, id)) = self.active_ids() else { return };
+        let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
+        match result {
+            Ok(label) => {
+                let info = &self.session.get(id).expect("exists").info;
+                self.views[i].document_changed(info);
+                self.notify(format!("{} {label}", if undo { "Undid" } else { "Redid" }));
+            }
+            Err(e) => self.notify(e.to_string()),
+        }
+    }
+
+    /// Apply any edit a view queued this frame (organize toolbar, keys).
+    pub(crate) fn process_pending_edits(&mut self) {
+        let Some(i) = self.active else { return };
+        if let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) {
+            self.apply_edit(edit);
+        }
+    }
+
+    /// Save the active document. Returns `true` if it was written.
+    pub fn save_active(&mut self, target: SaveTarget) -> bool {
+        match self.active {
+            Some(i) => self.save_view(i, target),
+            None => false,
+        }
+    }
+
+    /// Save the document shown in tab `index`. Returns `true` if it was written.
+    pub fn save_view(&mut self, index: usize, target: SaveTarget) -> bool {
+        let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
+        let Some(doc) = self.session.get(id) else { return false };
+        let (name, path) = (doc.name.clone(), doc.path.clone());
+        let bytes = match self.session.save_bytes(id) {
+            Ok(b) => b,
+            Err(e) => {
+                self.notify(format!("Couldn't save {name}: {e}"));
+                return false;
+            }
+        };
+        let destination = match (target, path, &self.save_override) {
+            (_, _, Some(p)) => Some(p.clone()),
+            (SaveTarget::InPlace, Some(p), _) => Some(p),
+            _ => self.ask_save_path(&name),
+        };
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let Some(dest) = destination else { return false };
+            if let Err(e) = write_atomically(&dest, &bytes) {
+                self.notify(format!("Couldn't save {dest}: {e}"));
+                return false;
+            }
+            match self.session.mark_saved(id, bytes, Some(dest.clone())) {
+                Ok(()) => {
+                    let info = &self.session.get(id).expect("exists").info;
+                    self.views[index].document_changed(info);
+                    self.notify(format!("Saved {}", short_name(&dest)));
+                    true
+                }
+                Err(e) => {
+                    self.notify(format!("Saved, but reopening failed: {e}"));
+                    false
+                }
+            }
+        }
+        #[cfg(target_arch = "wasm32")]
+        {
+            let _ = destination;
+            match download(&name, &bytes) {
+                Ok(()) => {
+                    let _ = self.session.mark_saved(id, bytes, None);
+                    let info = &self.session.get(id).expect("exists").info;
+                    self.views[index].document_changed(info);
+                    self.notify(format!("Downloaded {name}"));
+                    true
+                }
+                Err(e) => {
+                    self.notify(format!("Couldn't download {name}: {e}"));
+                    false
+                }
+            }
+        }
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    fn ask_save_path(&self, name: &str) -> Option {
+        let name = if name.to_ascii_lowercase().ends_with(".pdf") { name.to_string() } else { format!("{name}.pdf") };
+        rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().into_owned())
+    }
+
+    #[cfg(target_arch = "wasm32")]
+    fn ask_save_path(&self, _name: &str) -> Option {
+        None // browsers download instead
+    }
+
+    /// Close a tab, asking first if it has unsaved changes.
+    pub fn request_close_tab(&mut self, index: usize) {
+        let dirty = self.views.get(index).and_then(|v| self.session.get(v.id)).is_some_and(|d| d.dirty);
+        if dirty {
+            self.close_request = Some(CloseRequest::Tab(index));
+        } else {
+            self.close_tab(index);
+        }
+    }
+
+    /// The first tab with unsaved changes.
+    pub fn first_dirty(&self) -> Option {
+        self.views.iter().position(|v| self.session.get(v.id).is_some_and(|d| d.dirty))
+    }
+
+    /// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel.
+    pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option) {
+        let Some(req) = self.close_request.take() else { return };
+        let index = match req {
+            CloseRequest::Tab(i) => i,
+            CloseRequest::Quit => match self.first_dirty() {
+                Some(i) => i,
+                None => {
+                    self.quit(ctx);
+                    return;
+                }
+            },
+        };
+        match choice {
+            None => {} // cancelled: nothing closes
+            Some(save) => {
+                if save && !self.save_view(index, SaveTarget::InPlace) {
+                    return; // save failed or was cancelled: keep the document open
+                }
+                self.close_tab(index);
+                if req == CloseRequest::Quit {
+                    // Ask about the next dirty document, or quit.
+                    match self.first_dirty() {
+                        Some(_) => self.close_request = Some(CloseRequest::Quit),
+                        None => self.quit(ctx),
+                    }
+                }
+            }
+        }
+    }
+
+    fn quit(&mut self, ctx: &egui::Context) {
+        self.allow_quit = true;
+        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+    }
+
+    /// Intercept window close while documents have unsaved changes.
+    pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
+        if ctx.input(|i| i.viewport().close_requested()) && !self.allow_quit && self.first_dirty().is_some() {
+            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
+            self.close_request = Some(CloseRequest::Quit);
+        }
+    }
+}
+
+/// Write via a temporary file in the same directory and rename over the target, so a crash or
+/// full disk never leaves a half-written PDF where the original was.
+#[cfg(not(target_arch = "wasm32"))]
+pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
+    use std::io::Write;
+    let target = std::path::Path::new(path);
+    let dir = target.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
+    let tmp = dir.join(format!(".{}.printcraft-{}.tmp", target.file_name().and_then(|n| n.to_str()).unwrap_or("save"), std::process::id()));
+    let result = (|| {
+        let mut f = std::fs::File::create(&tmp)?;
+        f.write_all(bytes)?;
+        f.sync_all()?;
+        std::fs::rename(&tmp, target)
+    })();
+    if result.is_err() {
+        let _ = std::fs::remove_file(&tmp);
+    }
+    result
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn short_name(path: &str) -> String {
+    std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
+}
+
+/// Offer bytes as a browser download.
+#[cfg(target_arch = "wasm32")]
+fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
+    use wasm_bindgen::JsCast;
+    let err = |e: wasm_bindgen::JsValue| format!("{e:?}");
+    let array = js_sys::Uint8Array::from(bytes);
+    let parts = js_sys::Array::of1(&array);
+    let opts = web_sys::BlobPropertyBag::new();
+    opts.set_type("application/pdf");
+    let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
+    let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
+    let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
+    let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(err)?.dyn_into().map_err(|_| "anchor")?;
+    a.set_href(&url);
+    a.set_download(name);
+    a.click();
+    let _ = web_sys::Url::revoke_object_url(&url);
+    Ok(())
+}
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 7a5582a6b..5e5aa6ad4 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -32,6 +32,7 @@ pub fn show(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                         for id in RECOMMENDED {
                             let Some(g) = catalog::group(id) else { continue };
                             let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
+                            resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, g.label));
                             let fill = if resp.hovered() { t.hover } else { t.card };
                             ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
                             let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
@@ -57,6 +58,7 @@ pub fn show(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
                             }
                         }
                         let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
+                        resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Open file"));
                         ui.painter().rect(
                             rect,
                             CornerRadius::same(10),
@@ -81,6 +83,7 @@ pub fn show(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
             let mut open = None;
             for r in &app.recent {
                 let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
+                resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name));
                 if resp.hovered() {
                     ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
                 }
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 949a41117..32c7bdb43 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -42,6 +42,8 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) {
 pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
     let t = Tokens::get(ui.ctx());
     let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click());
+    let label = if tooltip.is_empty() { name } else { tooltip };
+    resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, label));
     let hovered = resp.hovered();
     if selected {
         ui.painter().rect_filled(rect, t.radius, t.accent_soft);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index b5c1f33ae..e40bfa030 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -8,6 +8,7 @@
 mod canvas;
 mod chrome;
 mod dialogs;
+mod editing;
 mod home;
 mod icon_data;
 pub mod icons;
@@ -19,6 +20,7 @@ mod widgets;
 use printcraft_engine::{DocId, Session};
 
 pub use canvas::DocView;
+pub use editing::{CloseRequest, SaveTarget};
 use theme::ThemeKind;
 
 /// Top-level workspace modes (Acrobat's mode bar).
@@ -67,9 +69,22 @@ pub enum Dialog {
 pub enum PropsTab {
     Description,
     Security,
+    Fonts,
     Advanced,
 }
 
+/// Files delivered asynchronously: (name, bytes).
+pub type Inbox = std::sync::Arc)>>>;
+
+pub struct PasswordPrompt {
+    pub name: String,
+    pub path: Option,
+    pub bytes: std::sync::Arc>,
+    pub input: String,
+    pub error: Option,
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
 pub struct RecentFile {
     pub name: String,
     pub path: String,
@@ -96,6 +111,17 @@ pub struct PrintCraftApp {
     pub toast: Option<(String, f64)>,
     /// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
     pub integrated_titlebar: bool,
+    pub password_prompt: Option,
+    pub full_screen: bool,
+    /// Files delivered asynchronously (web drag-and-drop, web file picker).
+    pub inbox: Inbox,
+    /// A pending "save changes?" question (closing a dirty tab or quitting).
+    pub close_request: Option,
+    /// Save to this path instead of asking (tests and automation).
+    pub save_override: Option,
+    /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords).
+    pub props_draft: Option<(DocId, [String; 4])>,
+    allow_quit: bool,
     pending_theme: Option,
     styled: bool,
     fonts_ready: bool,
@@ -126,16 +152,37 @@ impl PrintCraftApp {
             recent: Vec::new(),
             toast: None,
             integrated_titlebar: false,
+            password_prompt: None,
+            full_screen: false,
+            inbox: Default::default(),
+            close_request: None,
+            save_override: None,
+            props_draft: None,
+            allow_quit: false,
             pending_theme: None,
             styled: false,
             fonts_ready: false,
         }
     }
 
-    /// Open a document and make it the active tab.
+    /// Open a document and make it the active tab. Encrypted files raise the password prompt.
     pub fn open_bytes(&mut self, name: &str, path: Option, bytes: Vec) -> Result<(), String> {
+        self.try_open(name, path, std::sync::Arc::new(bytes), None)
+    }
+
+    fn try_open(&mut self, name: &str, path: Option, bytes: std::sync::Arc>, password: Option<&str>) -> Result<(), String> {
+        use printcraft_render::OpenError;
         let size = bytes.len();
-        let id = self.session.open(name, path.clone(), bytes).map_err(|e| e.to_string())?;
+        let id = match self.session.open(name, path.clone(), bytes.clone(), password) {
+            Ok(id) => id,
+            Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => {
+                let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
+                self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error });
+                return Ok(());
+            }
+            Err(e) => return Err(e.to_string()),
+        };
+        self.password_prompt = None;
         let doc = self.session.get(id).expect("just opened");
         let pages = doc.info.pages.len();
         // Acrobat opens straight to the Comments panel when a document has comments.
@@ -158,6 +205,81 @@ impl PrintCraftApp {
         Ok(())
     }
 
+    #[cfg(not(target_arch = "wasm32"))]
+    fn open_dropped(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) {
+        let p = f.path().to_string_lossy().into_owned();
+        if !p.is_empty() && f.path().is_absolute() {
+            self.open_path(&p);
+            return;
+        }
+        let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
+        match f.bytes() {
+            Ok(bytes) => {
+                if let Err(e) = self.open_bytes(&name, None, bytes) {
+                    self.notify(format!("Couldn't open {name}: {e}"));
+                }
+            }
+            Err(e) => self.notify(format!("Couldn't read {name}: {e}")),
+        }
+    }
+
+    /// Browsers read dropped files asynchronously; the bytes land in `inbox` and open next frame.
+    #[cfg(target_arch = "wasm32")]
+    fn open_dropped(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) {
+        let inbox = self.inbox.clone();
+        let ctx = ctx.clone();
+        wasm_bindgen_futures::spawn_local(async move {
+            let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
+            if let Ok(bytes) = f.bytes_async().await
+                && let Ok(mut q) = inbox.lock()
+            {
+                q.push((name, bytes));
+                ctx.request_repaint();
+            }
+        });
+    }
+
+    /// Save an attachment to disk, or open a PDF attachment in a new tab.
+    pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) {
+        let Some(d) = self.session.get(doc) else { return };
+        let Some(att) = d.info.attachments.get(index).cloned() else { return };
+        let data = printcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att);
+        match (data, open) {
+            (Err(e), _) => self.notify(format!("Couldn't read {}: {e}", att.name)),
+            (Ok(bytes), true) => {
+                if let Err(e) = self.open_bytes(&att.name, None, bytes) {
+                    self.notify(format!("Couldn't open {}: {e}", att.name));
+                }
+            }
+            (Ok(bytes), false) => {
+                #[cfg(not(target_arch = "wasm32"))]
+                if let Some(path) = rfd::FileDialog::new().set_file_name(&att.name).save_file() {
+                    match std::fs::write(&path, &bytes) {
+                        Ok(()) => self.notify(format!("Saved {}", path.display())),
+                        Err(e) => self.notify(format!("Couldn't save: {e}")),
+                    }
+                }
+                #[cfg(target_arch = "wasm32")]
+                self.notify(format!("Downloading attachments on the web arrives with M3.10 ({} bytes ready)", bytes.len()));
+            }
+        }
+    }
+
+    /// Enter or leave full-screen reading (Acrobat: View ▸ Full Screen Mode, ⌘L).
+    pub fn set_full_screen(&mut self, ctx: &egui::Context, on: bool) {
+        self.full_screen = on;
+        ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
+    }
+
+    /// Answer the password prompt (`None` cancels).
+    pub fn submit_password(&mut self, password: Option) {
+        let Some(p) = self.password_prompt.take() else { return };
+        let Some(pw) = password else { return };
+        if let Err(e) = self.try_open(&p.name, p.path, p.bytes, Some(&pw)) {
+            self.notify(format!("Couldn't open {}: {e}", p.name));
+        }
+    }
+
     #[cfg(not(target_arch = "wasm32"))]
     pub fn open_path(&mut self, path: &str) {
         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
@@ -176,6 +298,19 @@ impl PrintCraftApp {
         if let Some(p) = rfd::FileDialog::new().add_filter("PDF", &["pdf"]).pick_file() {
             self.open_path(&p.to_string_lossy());
         }
+        // Browsers pick files asynchronously; the bytes arrive through `inbox`.
+        #[cfg(target_arch = "wasm32")]
+        {
+            let inbox = self.inbox.clone();
+            wasm_bindgen_futures::spawn_local(async move {
+                if let Some(h) = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).pick_file().await {
+                    let bytes = h.read().await;
+                    if let Ok(mut q) = inbox.lock() {
+                        q.push((h.file_name(), bytes));
+                    }
+                }
+            });
+        }
     }
 
     pub fn close_tab(&mut self, index: usize) {
@@ -233,6 +368,30 @@ impl PrintCraftApp {
         }
     }
 
+    /// Serialize the user's persistent state (recent files, theme). Local only.
+    pub fn persist(&self) -> String {
+        serde_json::json!({ "recent": self.recent, "theme": self.theme }).to_string()
+    }
+
+    /// Restore state written by `persist`. Unknown or malformed data is ignored.
+    pub fn restore(&mut self, json: &str) {
+        let Ok(v) = serde_json::from_str::(json) else { return };
+        if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) {
+            // Only keep entries whose files still exist.
+            #[cfg(not(target_arch = "wasm32"))]
+            let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
+            self.recent = r;
+        }
+        if let Ok(t) = serde_json::from_value::(v["theme"].clone()) {
+            self.theme = t;
+        }
+    }
+
+    /// `true` while any open document still waits for page renders (used by headless capture).
+    pub fn render_pending(&self) -> bool {
+        self.views.iter().any(|v| v.render_pending())
+    }
+
     /// Apply a named view option (`--page 3`, `--panel bookmarks`, `--theme dark`, …).
     ///
     /// This is the seed of the UI control channel (M3.9): the same verbs become `ui.set` calls.
@@ -289,8 +448,37 @@ impl PrintCraftApp {
                 }
             }
             ("organize", Some(v)) => v.organize = value != "off",
+            ("rotate", Some(v)) => {
+                let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?;
+                if !deg.is_multiple_of(90) {
+                    return Err("rotate: 0, 90, 180 or 270".into());
+                }
+                v.rotation = deg % 360;
+            }
+            ("layer", _) => {
+                // `--layer "Name=off"` / `"Name=on"`
+                let (name, state) = value.rsplit_once('=').ok_or("expected NAME=on|off")?;
+                let (i, id) = self.active_ids().ok_or("`layer` needs an open document")?;
+                let idx = self
+                    .session
+                    .get(id)
+                    .and_then(|d| d.info.layers.iter().position(|l| l.name == name))
+                    .ok_or_else(|| format!("no layer named {name}"))?;
+                if self.session.set_layer_visible(id, idx, state != "off") {
+                    self.views[i].invalidate_content();
+                }
+            }
+            ("find", Some(v)) => {
+                v.open_find();
+                if let Some(f) = v.find.as_mut() {
+                    f.query = value.to_string();
+                }
+                v.rerun_find();
+            }
             ("fields", Some(v)) => v.highlight_fields = value != "off",
-            (k, None) if ["page", "zoom", "layout", "organize", "fields"].contains(&k) => return Err(format!("`{k}` needs an open document")),
+            (k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate"].contains(&k) => {
+                return Err(format!("`{k}` needs an open document"));
+            }
             (other, _) => return Err(format!("unknown option {other}")),
         }
         Ok(())
@@ -312,7 +500,28 @@ impl PrintCraftApp {
         if pressed(cmd(Key::W))
             && let Some(i) = self.active
         {
-            self.close_tab(i);
+            self.request_close_tab(i);
+        }
+        if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::S)) && self.active.is_some() {
+            self.save_active(SaveTarget::As);
+        }
+        if pressed(cmd(Key::S)) && self.active.is_some() {
+            self.save_active(SaveTarget::InPlace);
+        }
+        // Text fields keep their own undo; otherwise ⌘Z/⇧⌘Z step the document history.
+        if !ctx.egui_wants_keyboard_input() && self.active.is_some() {
+            if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z)) {
+                self.redo();
+            } else if pressed(cmd(Key::Z)) {
+                self.undo();
+            }
+        }
+        if pressed(cmd(Key::L)) && self.active.is_some() {
+            let on = !self.full_screen;
+            self.set_full_screen(ctx, on);
+        }
+        if self.full_screen && ctx.input(|i| i.key_pressed(Key::Escape)) {
+            self.set_full_screen(ctx, false);
         }
         if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::CTRL, Key::H)) {
             self.mode = if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read };
@@ -324,6 +533,10 @@ impl PrintCraftApp {
 }
 
 impl eframe::App for PrintCraftApp {
+    fn save(&mut self, storage: &mut dyn eframe::Storage) {
+        storage.set_string("printcraft", self.persist());
+    }
+
     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
         if !self.styled {
             egui_extras::install_image_loaders(ctx);
@@ -338,25 +551,17 @@ impl eframe::App for PrintCraftApp {
         }
         let dropped = ctx.input(|i| i.raw.dropped_files.clone());
         for f in dropped {
-            #[cfg(not(target_arch = "wasm32"))]
-            {
-                let p = f.path().to_string_lossy().into_owned();
-                if !p.is_empty() {
-                    self.open_path(&p);
-                    continue;
-                }
-            }
-            let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
-            match f.bytes() {
-                Ok(bytes) => {
-                    if let Err(e) = self.open_bytes(&name, None, bytes) {
-                        self.notify(format!("Couldn't open {name}: {e}"));
-                    }
-                }
-                Err(e) => self.notify(format!("Couldn't read {name}: {e}")),
+            self.open_dropped(f, ctx);
+        }
+        let arrived: Vec<(String, Vec)> = self.inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
+        for (name, bytes) in arrived {
+            if let Err(e) = self.open_bytes(&name, None, bytes) {
+                self.notify(format!("Couldn't open {name}: {e}"));
             }
         }
+        self.guard_quit(ctx);
         self.shortcuts(ctx);
+        self.process_pending_edits();
         // Pull finished renders into textures for every open document.
         for view in &mut self.views {
             if let Some(doc) = self.session.get(view.id) {
@@ -372,6 +577,20 @@ impl eframe::App for PrintCraftApp {
             ctx.request_repaint();
             return;
         }
+        if self.full_screen && self.active.is_some() {
+            // Full screen: the page, nothing else (Esc or ⌘L to leave).
+            let t = theme::Tokens::get(&ctx);
+            egui::CentralPanel::default().frame(egui::Frame::NONE.fill(if t.dark() { t.pasteboard } else { egui::Color32::from_gray(32) })).show(
+                ui,
+                |ui| {
+                    if let Some(i) = self.active {
+                        canvas::document_area(self, i, ui);
+                    }
+                },
+            );
+            dialogs::show(self, &ctx);
+            return;
+        }
         chrome::tab_strip(self, ui);
         chrome::mode_bar(self, ui);
         if self.active.is_some() {
@@ -388,6 +607,7 @@ impl eframe::App for PrintCraftApp {
             None => home::show(self, ui),
             Some(i) => canvas::document_area(self, i, ui),
         });
+        self.process_pending_edits();
         palette::show(self, &ctx);
         dialogs::show(self, &ctx);
         widgets::toast(self, &ctx);
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b302f8495..fcad6f8f2 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -74,23 +74,31 @@ fn all_tools(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) {
 
 fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
     let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, g.label));
     if resp.hovered() {
         ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
     }
     icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g));
     ui.painter().text(rect.left_center() + vec2(36.0, 0.0), Align2::LEFT_CENTER, g.label, theme::regular(13.5), t.text);
-    let (chip, fill) = match (g.badge, g.availability) {
-        (Some(b), _) => (Some(b.to_string()), t.badge_new),
-        (None, Availability::Planned(m)) => (Some(m.to_string()), t.pressed),
-        _ => (None, t.pressed),
-    };
-    if let Some(c) = chip {
-        let font = theme::semibold(9.5);
-        let fg = if fill == t.badge_new { Color32::WHITE } else { t.text_muted };
-        let w = ui.fonts_mut(|f| f.layout_no_wrap(c.clone(), font.clone(), fg).size().x);
-        let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
-        ui.painter().rect_filled(r, CornerRadius::same(4), fill);
-        ui.painter().text(r.center(), Align2::CENTER_CENTER, c, font, fg);
+    match (g.badge, g.availability) {
+        (Some(b), _) => {
+            let font = theme::semibold(9.5);
+            let w = ui.fonts_mut(|f| f.layout_no_wrap(b.to_string(), font.clone(), Color32::WHITE).size().x);
+            let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
+            ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new);
+            ui.painter().text(r.center(), Align2::CENTER_CENTER, b, font, Color32::WHITE);
+        }
+        // Planned tools: a quiet milestone hint instead of a chip, so the list stays calm.
+        (None, Availability::Planned(m)) if resp.hovered() => {
+            ui.painter().text(
+                rect.right_center() - vec2(10.0, 0.0),
+                Align2::RIGHT_CENTER,
+                format!("Planned · {m}"),
+                theme::medium(10.5),
+                t.text_faint,
+            );
+        }
+        _ => {}
     }
     let tip = match g.availability {
         Availability::Ready => "Available".to_string(),
@@ -123,6 +131,7 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat
             widgets::section_title(ui, s.title);
             for item in s.items {
                 let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
+                resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, item.label));
                 let ready = item.availability == Availability::Ready;
                 if resp.hovered() {
                     ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
@@ -170,7 +179,8 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) {
     let Some(panel) = app.right else { return };
     let mut nav: Option