diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..50ff3e96c4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +## What this changes + + + +## How you verified it + + + +- [ ] `cargo fmt --check` +- [ ] clippy with `-D warnings` on the crates I changed +- [ ] tests pass on the crates I changed +- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset) + +**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a +crate integration test or an end-to-end one. For a bug fix, **please check that the test +fails before your fix** by reverting the fix and watching it go red. A test nobody has seen +fail is not evidence that it pins anything. + +## Never-crash + +Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4). + +- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is + provably infallible with a comment saying why +- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char + boundaries); checked or saturating arithmetic; allocations sized by input are capped; + recursion is bounded +- [ ] No `unsafe` +- [ ] Any crash this fixes has a small synthetic regression test + +## Provenance + +This is the part that cannot be undone after a merge, and the part a new contributor is +least likely to know about. Please read `AGENTS.md` §1 before adding any asset. + +- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated +- [ ] No font files committed here (they live in + [storytold/craft-fonts](https://github.com/storytold/craft-fonts)) +- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be + run as external oracle processes +- [ ] Behaviour comes from the specification or black-box observation, **not** from reading + another implementation's source +- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes + +## Security + +- [ ] `--root` confinement still holds on every path, including ones reached by nested + tool dispatch +- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated +- [ ] No new outbound network access (that decision is the owner's) + +## Parity and reach + +- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still* + missing — `shipped` means "exists and tested", not "as good as Acrobat" +- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3), + so agents can drive it and not just the UI +- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README) + +## Anything a reviewer should look at first + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f227be879..c6ee0e1c31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/AGENTS.md b/AGENTS.md index 039cd6a8b1..2ac90cfdf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, - Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs. - The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: - The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency. - - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces, and are embedded as code in hayro. + - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14.rs` (approved by the owner, 2026-10-08), which is what generated appearances measure with and what survives hayro being retired. Adding anything to this list needs the user's explicit approval. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 40f1c008c5..be77e718b2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (268) +## In this repository (270) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -27,6 +27,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -221,6 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | +| `crates/fonts/src/std14.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 6aafc77ecd..28785f7675 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e04ab5246125efae2bad439e62c150fc10649c4b6796b30565615f5bd7509ec6" +sha256 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0" +sha256 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f" +sha256 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729" +sha256 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744" +sha256 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830" +sha256 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2" +sha256 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442" +sha256 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48" +sha256 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b" +sha256 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c" +sha256 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c" +sha256 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9" +sha256 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d" +sha256 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3" +sha256 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0" +sha256 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e" +sha256 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b" +sha256 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1" +sha256 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1" + +[[asset]] +path = "crates/ui-egui/src/i18n/lt.tsv" +title = "Lithuanian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f" [[asset]] path = "assets/icons/accessibility.svg" @@ -2353,6 +2364,18 @@ kind = "data" usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" +[[asset]] +path = "crates/fonts/src/std14.rs" +title = "Standard-14 font advance widths and encodings (as Rust tables)" +author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors" +source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs." +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "data" +adobe_data = true +usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." +sha256 = "4ce47db7e65467d4ea1d1642ff76dfcaeb9669c1811a93819d7e00e63fa9fcf8" + [[bundled]] crate = "epaint_default_fonts" version = "0.36.2" @@ -3328,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086" +sha256 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e" diff --git a/CLAUDE.md b/CLAUDE.md index 77d6445b4e..12779b1e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,15 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. -- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0). -- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment. +- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. + - Tests mirror the zooms: property → unit → crate integration → end-to-end. + - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. + - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. +- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. +- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. +- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. + - **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once. ## Running and looking at the app - `cargo run -p pdfcraft -- ` opens the desktop app. diff --git a/Cargo.lock b/Cargo.lock index 6a1b6b1667..8d2aac712b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,12 +3561,13 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.7.0" +version = "0.7.1" dependencies = [ "eframe", "egui", "fmv-macos-events", "log", + "pdfcraft-engine", "pdfcraft-ui-egui", "pollster 1.0.1", "rustls-native-certs", @@ -3582,10 +3583,11 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "thiserror 2.0.21", @@ -3593,7 +3595,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3604,7 +3606,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3615,7 +3617,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "flate2", @@ -3631,11 +3633,12 @@ dependencies = [ "png 0.18.1", "serde_json", "sha2", + "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -3650,18 +3653,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.7.0" +version = "0.7.1" [[package]] name = "pdfcraft-content" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -3673,7 +3676,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.7.0" +version = "0.7.1" dependencies = [ "encoding_rs", "hayro-syntax", @@ -3684,11 +3687,12 @@ dependencies = [ "png 0.18.1", "thiserror 2.0.21", "tiff", + "zune-jpeg", ] [[package]] name = "pdfcraft-crypt" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes", "md-5", @@ -3700,7 +3704,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -3715,7 +3719,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.7.0" +version = "0.7.1" dependencies = [ "chrono", "icu_properties", @@ -3752,14 +3756,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.7.0" +version = "0.7.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.7.0" +version = "0.7.1" dependencies = [ "flate2", "proptest", @@ -3769,7 +3773,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.7.0" +version = "0.7.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -3779,7 +3783,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -3791,18 +3795,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.7.0" +version = "0.7.1" [[package]] name = "pdfcraft-js" -version = "0.7.0" +version = "0.7.1" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3814,14 +3818,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.7.0" +version = "0.7.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -3831,7 +3835,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.7.0" +version = "0.7.1" dependencies = [ "image", "pdfcraft-annot", @@ -3844,7 +3848,7 @@ dependencies = [ [[package]] name = "pdfcraft-organize" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -3854,7 +3858,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-channel", "futures-lite", @@ -3863,7 +3867,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-cos", "pdfcraft-model", @@ -3871,7 +3875,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.7.0" +version = "0.7.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -3882,7 +3886,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3896,7 +3900,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.7.0" +version = "0.7.1" dependencies = [ "bytemuck", "flate2", @@ -3915,7 +3919,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes", "aws-lc-rs", @@ -3949,7 +3953,7 @@ dependencies = [ [[package]] name = "pdfcraft-ui-egui" -version = "0.7.0" +version = "0.7.1" dependencies = [ "arboard", "base64 0.22.1", @@ -3976,6 +3980,7 @@ dependencies = [ "png 0.18.1", "pollster 1.0.1", "rfd", + "rustix 1.1.5", "serde", "serde_json", "skrifa 0.44.0", @@ -3990,7 +3995,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.7.0" +version = "0.7.1" dependencies = [ "eframe", "egui", @@ -4002,7 +4007,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.7.0" +version = "0.7.1" dependencies = [ "base64 0.22.1", "image", @@ -4016,7 +4021,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.7.0" +version = "0.7.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 4220db770a..169af97f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index 57d1964ca8..a035c9bf35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,14 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -140,13 +148,27 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. +- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. +- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. +- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged. + +- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged. +- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged. +- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged. +- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. +- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. +- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. +- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. - **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. - **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. - **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. - **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. - **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged. - **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open. +- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged. - **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. - **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. - **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. @@ -173,6 +195,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. +- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. - 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged. - **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. @@ -400,6 +424,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | +| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/batch.rs b/apps/pdfcraft-cli/src/batch.rs new file mode 100644 index 0000000000..5d9a700440 --- /dev/null +++ b/apps/pdfcraft-cli/src/batch.rs @@ -0,0 +1,61 @@ +//! Friendly flags for the same folder-job tool exposed by `run` and MCP. + +use serde_json::json; + +use crate::{CliError, OptionSpec, automation, flag, print_output, validate_options}; + +const OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--root", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--steps", takes_value: true }, + OptionSpec { name: "--recursive", takes_value: false }, + OptionSpec { name: "--stop-on-error", takes_value: false }, + OptionSpec { name: "--ppi", takes_value: true }, + OptionSpec { name: "--quality", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--language", takes_value: true }, + OptionSpec { name: "--ocr-all-pages", takes_value: false }, +]; + +pub(super) fn run(args: &[String]) -> Result<(), CliError> { + validate_options("batch", args, OPTIONS)?; + let mut paths = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(spec) = OPTIONS.iter().find(|s| s.name == arg) { + if !seen.insert(spec.name) { + return Err(format!("batch: {} may only be supplied once", spec.name).into()); + } + if spec.takes_value { + if args.get(i + 1).is_none_or(|v| v.is_empty() || v.starts_with("--")) { + return Err(format!("batch: {} needs a value", spec.name).into()); + } + i += 1; + } + } else { + paths.push(arg); + } + i += 1; + } + let steps: Vec<_> = flag(args, "--steps").ok_or("batch: missing --steps (convert, ocr, optimize)")?.split(',').collect(); + let out = flag(args, "--out-dir").ok_or("batch: missing --out-dir")?; + let mut params = + json!({"paths":paths,"steps":steps,"out_dir":out,"recursive":seen.contains("--recursive"),"stop_on_error":seen.contains("--stop-on-error")}); + for (option, key) in [("--ppi", "ppi"), ("--quality", "quality"), ("--dpi", "dpi")] { + if let Some(v) = flag(args, option) { + params[key] = serde_json::from_str(v).map_err(|_| format!("batch: {option} needs a number"))?; + } + } + if let Some(lang) = flag(args, "--language") { + params["language"] = json!(lang); + } + if seen.contains("--ocr-all-pages") { + params["skip_text_pages"] = json!(false); + } + let mut auto = automation(args)?; + let content = auto.call("doc_batch", ¶ms).map_err(|e| e.to_string())?; + let failed = content.iter().any(|c| matches!(c, pdfcraft_automation::Content::Json(v) if v["failed"].as_u64().is_some_and(|n| n > 0))); + print_output(&auto, content, None)?; + if failed { Err("batch: one or more files failed; see the JSON report".into()) } else { Ok(()) } +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index b33cd894cd..190cb3da9f 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -17,6 +17,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod batch; + use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; @@ -51,11 +53,15 @@ pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .t pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] [--insert-blank 1] [--title T] [--author A] [--full] + (options run in order; page numbers refer to the document after the earlier options) pdfcraft-cli combine … --out combined.pdf pdfcraft-cli extract --pages 1,3,5 --out out.pdf pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] pdfcraft-cli audit [--password PW] find faux redactions (fails when found) +pdfcraft-cli batch --steps convert,ocr,optimize --out-dir DIR + [--recursive] [--root DIR] [--stop-on-error] + [--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages] pdfcraft-cli tools automation tools and their JSON Schemas pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …] @@ -89,6 +95,7 @@ fn main() -> ExitCode { Some("audit") => audit(&args[1..]), Some("tools") => tools(), Some("run") => run(&args[1..]), + Some("batch") => batch::run(&args[1..]), Some("ui") => ui(&args[1..]), #[cfg(feature = "mcp")] Some("mcp") => mcp(&args[1..]), @@ -141,7 +148,7 @@ fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> { } fn version() -> Result<(), CliError> { - stdout_line(format_args!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION")))?; + stdout_line(format_args!("pdfcraft-cli {}", pdfcraft_engine::build_info::version()))?; stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?; stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?; stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; @@ -331,11 +338,20 @@ fn text(args: &[String]) -> Result<(), CliError> { // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask // `inspect` why instead, so a protected file fails the same way `info` does. if r.page_count() == 0 { - let info = inspect(bytes, password).map_err(|e| format!("text: {e}"))?; + let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?; if !info.pages.is_empty() { return Err("text: the document could not be parsed".into()); } } + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + } let pages: Vec = match selected_page { Some(page) => vec![page], None => (0..r.page_count()).collect(), @@ -439,6 +455,17 @@ fn edit(args: &[String]) -> Result<(), CliError> { if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); } + // Pages count from the document as the earlier options left it, so a page past the end is + // reported with its own option and the current page count (#1550). + let listed = match &e { + Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), + Edit::DeletePages { pages } => Some(("--delete", pages)), + Edit::MovePages { pages, .. } => Some(("--move", pages)), + _ => None, + }; + if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { + return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); + } session.apply(id, e).map_err(|e| e.to_string())?; } let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; @@ -610,8 +637,20 @@ fn short(s: &str) -> String { fn collect(paths: &[&str]) -> Vec { let mut out = Vec::new(); let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + let mut visited = std::collections::HashSet::new(); while let Some(p) = stack.pop() { if p.is_dir() { + // Directory aliases and symlink cycles must not scan the same tree repeatedly. + let identity = match p.canonicalize() { + Ok(identity) => identity, + Err(error) => { + eprintln!("{}: could not resolve directory: {error}", p.display()); + continue; + } + }; + if !visited.insert(identity) { + continue; + } if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/batch.rs new file mode 100644 index 0000000000..1a751f66a8 --- /dev/null +++ b/apps/pdfcraft-cli/tests/batch.rs @@ -0,0 +1,46 @@ +use serde_json::Value; +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +#[test] +fn cli_batch_reports_partial_failure_and_continues_into_subfolders() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("input/sub")).unwrap(); + std::fs::write(dir.join("input/0.pdf"), b"broken").unwrap(); + std::fs::write(dir.join("input/sub/page.txt"), "Batch text").unwrap(); + let output = Command::new(BIN) + .args(["batch", "input", "--root"]) + .arg(&dir) + .args(["--steps", "convert,optimize", "--out-dir", "out", "--recursive"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["failed"], 1); + let converted = dir.join("out/sub/page.pdf"); + assert!(converted.exists()); + let text = Command::new(BIN).arg("text").arg(converted).output().unwrap(); + assert!(text.status.success()); + assert!(String::from_utf8_lossy(&text.stdout).contains("Batch text")); + let success = Command::new(BIN).current_dir(&dir).args(["batch", "input/sub", "--steps", "convert", "--out-dir", "other"]).output().unwrap(); + assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr)); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn cli_batch_rejects_bad_flags_before_creating_outputs() { + for args in [ + vec!["batch", "--steps"], + vec!["batch", "--recursive", "--root"], + vec!["batch", "--steps", "convert", "--out-dir"], + vec!["batch", "--steps", "convert", "--steps", "ocr", "--out-dir", "out"], + vec!["batch", "--unknown"], + ] { + let output = Command::new(BIN).args(args).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + } +} diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/check.rs new file mode 100644 index 0000000000..a206df5912 --- /dev/null +++ b/apps/pdfcraft-cli/tests/check.rs @@ -0,0 +1,111 @@ +//! The robustness harness visits each directory once, including symlink aliases and cycles. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +struct Workdir(PathBuf); + +impl Workdir { + fn new(test: &str) -> Self { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-check-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("input/nested")).unwrap(); + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 20 30] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + std::fs::write(dir.join("input/a.pdf"), &bytes).unwrap(); + std::fs::write(dir.join("input/nested/b.PDF"), &bytes).unwrap(); + Self(dir) + } + + fn check(&self, inputs: &[&Path]) -> Vec { + let report = self.0.join("report.json"); + let mut child = Command::new(BIN) + .arg("check") + .args(inputs) + .args(["--dpi", "36", "--timeout", "2", "--json"]) + .arg(&report) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("check did not finish scanning directories: {}", String::from_utf8_lossy(&output.stderr)); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap() + } +} + +impl Drop for Workdir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn assert_two_valid_pdfs(results: &[serde_json::Value]) { + assert_eq!(results.len(), 2, "each PDF should be checked once: {results:?}"); + for result in results { + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["pages"], 1, "{result}"); + } + assert!(results[0]["file"].as_str().unwrap().ends_with("a.pdf")); + assert!(results[1]["file"].as_str().unwrap().ends_with("b.PDF")); +} + +#[test] +fn check_scans_overlapping_directories_once() { + let dir = Workdir::new("overlap"); + let input = dir.0.join("input"); + assert_two_valid_pdfs(&dir.check(&[&input, &input, &input.join("nested")])); +} + +#[cfg(unix)] +#[test] +fn check_scans_a_directory_symlink_cycle_once() { + let dir = Workdir::new("cycle"); + let input = dir.0.join("input"); + std::os::unix::fs::symlink(".", input.join("again")).unwrap(); + assert_two_valid_pdfs(&dir.check(&[&input])); +} + +#[cfg(unix)] +#[test] +fn check_follows_directory_and_file_symlinks() { + let dir = Workdir::new("aliases"); + let input = dir.0.join("input"); + let alias = dir.0.join("alias"); + std::os::unix::fs::symlink(&input, &alias).unwrap(); + assert_two_valid_pdfs(&dir.check(&[&input, &alias])); + + let file_alias = dir.0.join("page.pdf"); + std::os::unix::fs::symlink(input.join("a.pdf"), &file_alias).unwrap(); + let results = dir.check(&[&file_alias]); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["status"], "ok"); + assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref()); +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index b7c3439d09..c9a5e7e2b3 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -224,3 +224,18 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() { assert_eq!(sizes, expected, "{name}"); } } + +/// A page past the end names its own option and the page count left by the earlier options (#1550). +#[test] +fn a_page_past_the_end_after_earlier_edits_names_that_option() { + let input = tmp("chained", "in.pdf"); + std::fs::write(&input, fixture()).unwrap(); + let output = tmp("chained", "out.pdf"); + // 1 page, grown to 3, then --delete leaves 2: the 3 in --move 3:1 is the page that is gone. + let args = ["--insert-blank", "2", "--insert-blank", "3", "--delete", "3", "--move", "3:1"]; + let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()]).args(args).output().unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("--move: page 3 does not exist") && err.contains("has 2 pages"), "{err}"); + assert!(!output.exists()); +} diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/text.rs index 5ac2e07c7d..3d39a9e284 100644 --- a/apps/pdfcraft-cli/tests/text.rs +++ b/apps/pdfcraft-cli/tests/text.rs @@ -96,6 +96,41 @@ fn text_of_a_protected_document_with_the_password_succeeds() { assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}"); } +/// `three.pdf` encrypted so that the open password `openme` doesn't allow copying, and the +/// permissions password `boss` lifts that. +fn copy_protected_fixture() -> std::path::PathBuf { + let src = tmp("to-protect-no-copy.pdf"); + std::fs::write(&src, fixture(3)).unwrap(); + let out_path = tmp("protected-no-copy.pdf"); + let script = tmp("protect-no-copy.json"); + let steps = serde_json::json!([ + { "tool": "doc_open", "args": { "path": src } }, + { "tool": "doc_protect", "args": { "doc": 1, "open_password": "openme", "permissions_password": "boss", "copy": false } }, + { "tool": "doc_save", "args": { "doc": 1, "path": out_path } }, + ]); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = Command::new(BIN).args(["run", "--script", script.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "protecting the fixture failed: {}", String::from_utf8_lossy(&out.stderr)); + out_path +} + +/// `text` copies text out, so it asks what `text_extract` asks (#1645): the open password alone +/// gives no text when copying isn't allowed, the permissions password does. +#[test] +fn text_respects_a_document_that_does_not_allow_copying() { + let path = copy_protected_fixture(); + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "openme"]).output().unwrap(); + assert!(!out.status.success(), "copying isn't allowed: {}", String::from_utf8_lossy(&out.stdout)); + assert!(out.stdout.is_empty(), "{}", String::from_utf8_lossy(&out.stdout)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("don't allow copying"), "the reason must be named: {stderr}"); + assert!(stderr.contains("--password"), "and the way in: {stderr}"); + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "boss"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}"); +} + mod page_argument_tests { use super::*; use std::path::PathBuf; diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a0f051c112..4a185d1f92 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,6 +14,8 @@ workspace = true ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } +# `build_info`: the version and compile-time build label for `--version` and the log. +pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } diff --git a/apps/pdfcraft/src/decorations.rs b/apps/pdfcraft/src/decorations.rs new file mode 100644 index 0000000000..f21faf3d01 --- /dev/null +++ b/apps/pdfcraft/src/decorations.rs @@ -0,0 +1,70 @@ +//! Who draws the window frame on Linux. +//! +//! Wayland compositors that draw frames for their clients offer `zxdg_decoration_manager_v1` +//! (KDE, Sway, Hyprland, COSMIC). GNOME doesn't: it leaves the frame to the app, and winit then +//! drew an imitation of GNOME's. Without that protocol PdfCraft turns winit's frame off and draws +//! a header bar like GNOME's own apps (`pdfcraft_ui_egui::window_controls`), with the window +//! buttons GNOME's `button-layout` names. X11 window managers always draw the frame. +//! +//! `PDFCRAFT_TITLEBAR=app` or `=system` overrides the choice. + +use pdfcraft_ui_egui::window_controls::Layout; + +/// The window buttons to draw in the app, or `None` when the desktop draws the frame. +pub(crate) fn app_drawn() -> Option { + let drawn = match std::env::var("PDFCRAFT_TITLEBAR").ok().as_deref().map(str::trim) { + Some("app") => true, + Some("system") => false, + _ => compositor_leaves_frame_to_app(), + }; + drawn.then(button_layout) +} + +/// GNOME's button order, or minimize, maximize and close on the right when it names none. +fn button_layout() -> Layout { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] + if let Some(value) = crate::text_scale::gsettings(&["get", "org.gnome.desktop.wm.preferences", "button-layout"]) { + let layout = Layout::parse(&value); + // A layout without a single button would leave no way to close the window by mouse. + if !layout.left.is_empty() || !layout.right.is_empty() { + return layout; + } + } + Layout::default() +} + +/// Whether this is a Wayland session whose compositor draws no frames. +#[cfg(target_os = "linux")] +fn compositor_leaves_frame_to_app() -> bool { + use wayland_client::Connection; + use wayland_client::globals::registry_queue_init; + // No Wayland compositor: X11, whose window manager draws the frame (winit falls back to it). + let Ok(conn) = Connection::connect_to_env() else { return false }; + let Ok((globals, _queue)) = registry_queue_init::(&conn) else { return false }; + let offered = globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")); + if !offered { + log::info!("the Wayland compositor draws no window frames; PdfCraft draws its own title bar"); + } + !offered +} + +#[cfg(not(target_os = "linux"))] +fn compositor_leaves_frame_to_app() -> bool { + false +} + +#[cfg(target_os = "linux")] +struct Registry; + +#[cfg(target_os = "linux")] +impl wayland_client::Dispatch for Registry { + fn event( + _: &mut Self, + _: &wayland_client::protocol::wl_registry::WlRegistry, + _: wayland_client::protocol::wl_registry::Event, + _: &wayland_client::globals::GlobalListContents, + _: &wayland_client::Connection, + _: &wayland_client::QueueHandle, + ) { + } +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 348689a5c0..05d130e5d4 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -34,6 +34,7 @@ use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] mod apple_events; mod compositor_gpu; +mod decorations; mod logging; #[cfg(target_os = "windows")] mod motion; @@ -129,6 +130,35 @@ fn path_from_arg(arg: String) -> String { String::from_utf8(out).unwrap_or(arg) } +/// Some programs start the PDF reader with the path unquoted (`pdfcraft.exe P:\Docs\01 Contract.PDF`), +/// so a path with spaces arrives split into several arguments (Acrobat accepts this). When an +/// argument names no file but it and the ones after it, joined with one space, do, rejoin them +/// (the longest such run). Anything else is kept as given. Runs of several spaces can't be recovered: the split has lost them. +fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut i = 0; + while let Some(first) = args.get(i) { + if exists(first) { + out.push(first.clone()); + i += 1; + continue; + } + // The longest run starting here that names a file; capped so a long list stays cheap. + let end = (i + 2..=args.len().min(i + 32)).rev().find(|&j| args.get(i..j).is_some_and(|run| exists(&run.join(" ")))); + match end.and_then(|j| Some((j, args.get(i..j)?.join(" ")))) { + Some((j, joined)) => { + out.push(joined); + i = j; + } + None => { + out.push(first.clone()); + i += 1; + } + } + } + out +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); @@ -149,21 +179,24 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let startup = startup::Arguments::parse(std::env::args().skip(1)); + let mut startup = startup::Arguments::parse(std::env::args().skip(1)); if startup.version { - println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); + println!("pdfcraft {}", pdfcraft_engine::build_info::version()); return Ok(()); } + startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); let may_hand_off = startup.may_hand_off(); let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); + // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). + let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; migrate_legacy_folders(); // The log file lives in the settings folder; opened after the arguments (so `--version` leaves // no file behind) and after the PrintCraft migration (which a fresh folder would block). // Records logged until now are written to it first. if let (Some(logger), Some(dir)) = (logger, settings_dir()) { match logger.attach_dir(&dir.join("logs")) { - Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. Err(e) => log::warn!("no log file: {e}"), } @@ -177,7 +210,7 @@ fn main() -> eframe::Result { single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, two_paged }; + let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -187,7 +220,7 @@ fn main() -> eframe::Result { let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu }; let result = eframe::run_native( "PdfCraft", - native_options(integrated, first), + native_options(launch.integrated, launch.window_controls.is_some(), first), app_creator( launch.clone(), Rc::clone(&started), @@ -205,7 +238,7 @@ fn main() -> eframe::Result { log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu."); eframe::run_native( "PdfCraft", - native_options(integrated, eframe::Renderer::Glow), + native_options(launch.integrated, launch.window_controls.is_some(), eframe::Renderer::Glow), app_creator( launch, started, @@ -245,7 +278,7 @@ fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool { } /// The window and renderer settings for one run. -fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions { +fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) -> eframe::NativeOptions { let mut viewport = egui::ViewportBuilder::default() .with_title("PdfCraft") .with_inner_size([1440.0, 920.0]) @@ -261,6 +294,10 @@ fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::Nativ if integrated { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } + if app_frame { + // The tab strip is the title bar (pdfcraft_ui_egui::window_controls). + viewport = viewport.with_decorations(false); + } let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; @@ -278,6 +315,8 @@ struct Launch { control_file: Option, create_images: bool, integrated: bool, + /// The window buttons the app draws, when the desktop leaves the frame to it. + window_controls: Option, two_paged: bool, } @@ -287,7 +326,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, two_paged } = launch; + let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -301,6 +340,7 @@ fn app_creator<'a>( app.restore(&json); } app.integrated_titlebar = integrated; + app.window_controls = window_controls; app.system_text_scale = text_scale::system(); // Windows' Animation effects setting, which Preferences ▸ Animation effects follows unless // it is set to On or Off. @@ -346,6 +386,7 @@ fn app_creator<'a>( &[("folder", &w.folder.display().to_string()), ("error", &w.error)], ); } + let defer_layout = two_paged && !files.is_empty() && !create_images; if create_images { if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); @@ -357,7 +398,8 @@ fn app_creator<'a>( startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { + let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) }; + if let Err(e) = result { log::warn!("--{k} {v}: {e}"); } } @@ -470,6 +512,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// its window appeared. D3D12 is the native, best-supported backend there. fn configure_gpu(native: &mut eframe::NativeOptions) { let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return }; + // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a + // compute shader when the device is created, one that Metal can't compile on an Intel Iris + // Pro: the device is lost and the window falls back to OpenGL. `WGPU_VALIDATION_INDIRECT_CALL=1` + // turns the check back on. + setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in // Surface::configure before the app starts. Keep the renderer's other device requirements, @@ -713,6 +760,24 @@ mod tests { assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); } + #[test] + fn unquoted_paths_with_spaces_are_rejoined() { + let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; + let exists = |p: &str| have.contains(&p); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // The case an office program produced: one path, split at its space. + assert_eq!( + super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), + args(&["P:\\Archive\\00042A26 Contract.PDF"]) + ); + // The longest run wins; files that exist on their own are left alone. + assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); + assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); + // Nothing to join: kept as given, so the missing file is still reported by name. + assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); + assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); + } + #[test] fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); @@ -738,8 +803,15 @@ mod tests { assert!(!retry_with_gl(RendererChoice::Wgpu, false)); assert!(!retry_with_gl(RendererChoice::Gl, false)); // Each run states its renderer rather than relying on eframe's default. - assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); - assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); + assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); + } + + #[test] + fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { + use super::native_options; + assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); + assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); } #[test] @@ -767,6 +839,10 @@ mod tests { if std::env::var_os("WGPU_POWER_PREF").is_none() { assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + let flags = setup.instance_descriptor.flags; + assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); + } if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { let backends = setup.instance_descriptor.backends; assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); @@ -886,7 +962,7 @@ mod tests { // than the device allows. use eframe::wgpu; let _gpu = gpu(); - let native = super::native_options(false, eframe::Renderer::Wgpu); + let native = super::native_options(false, false, eframe::Renderer::Wgpu); let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; @@ -953,7 +1029,7 @@ mod tests { fn pipeline_creation_error_is_returned_and_device_remains_usable() { use eframe::{egui_wgpu, wgpu}; let _gpu = gpu(); - let mut native = super::native_options(false, eframe::Renderer::Wgpu); + let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs index 50ae1198d7..14ec7649ae 100644 --- a/apps/pdfcraft/src/startup.rs +++ b/apps/pdfcraft/src/startup.rs @@ -102,6 +102,17 @@ mod tests { assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); } + #[test] + fn newer_options_and_version_are_preserved() { + let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]); + assert!(parsed.two_paged && parsed.new_window); + assert_eq!(parsed.files, ["/tmp/a b.pdf"]); + assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]); + assert!(!parsed.may_hand_off()); + let version = parse(&["--two-paged", "--version", "ignored.pdf"]); + assert!(version.version && version.files.is_empty()); + } + #[test] fn malformed_boolean_does_not_eat_a_file_or_option() { let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); @@ -139,7 +150,11 @@ mod tests { assert_eq!(persisted["default_layout"], "single"); assert!(persisted.get("two_paged").is_none()); // Existing --layout applies after the flag to the active document only. - app.set_option("layout", "continuous").unwrap(); + if enabled { + app.set_launch_layout("continuous").unwrap(); + } else { + app.set_option("layout", "continuous").unwrap(); + } assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); assert!(app.views[2].continuous()); app.set_option("default-layout", "two-up").unwrap(); @@ -150,4 +165,34 @@ mod tests { } std::fs::remove_file(path).unwrap(); } + + #[test] + fn explicit_launch_layout_preserves_argument_order_and_active_file() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let name = path.to_str().unwrap(); + for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] { + for count in [1, 2] { + let args = Arguments::parse(prefix.into_iter().chain(std::iter::repeat_n(name, count)).map(str::to_owned)); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + open_files(&mut app, &args.files, args.two_paged); + for (key, value) in args.options { + assert_eq!(key, "layout"); + app.set_launch_layout(&value).unwrap(); + } + assert_eq!(app.active, Some(count)); + assert!(app.views[0].continuous()); + for view in app.views.iter().skip(1).take(count - 1) { + assert_eq!(view.layout, PageLayout::TwoUp); + } + assert_eq!(app.views[count].layout, PageLayout::Single); + assert!(!app.views[count].continuous()); + assert!(app.view_defaults.continuous); + } + } + std::fs::remove_file(path).unwrap(); + } } diff --git a/apps/pdfcraft/src/text_scale.rs b/apps/pdfcraft/src/text_scale.rs index 8bb8ea51ab..76e0d020ca 100644 --- a/apps/pdfcraft/src/text_scale.rs +++ b/apps/pdfcraft/src/text_scale.rs @@ -27,7 +27,7 @@ fn parse(output: &str) -> f32 { /// Run `gsettings` and return what it printed, or `None` if it is missing, fails or hangs. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))] -fn gsettings(args: &[&str]) -> Option { +pub(crate) fn gsettings(args: &[&str]) -> Option { use std::io::Read; use std::process::{Command, Stdio}; let mut child = Command::new("gsettings").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; diff --git a/contributors/people.toml b/contributors/people.toml index 9bf6fd9fe6..aa8dd7a158 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -126,3 +126,6 @@ sync_github_name = true real_name = "Jeremy Yao" display_name = "Jeremy Yao" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" diff --git a/crates/a11y/Cargo.toml b/crates/a11y/Cargo.toml index adef6053a1..42a86bbad2 100644 --- a/crates/a11y/Cargo.toml +++ b/crates/a11y/Cargo.toml @@ -14,5 +14,6 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-content = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-fonts = { workspace = true } thiserror.workspace = true diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs new file mode 100644 index 0000000000..28a613f366 --- /dev/null +++ b/crates/a11y/src/autotag.rs @@ -0,0 +1,282 @@ +//! Create a valid, reviewable structure tree for PDFs whose painted content is text only. +//! +//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph, +//! which can then be retagged with the existing tag-property tool after human review. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +const MAX_PAGES: usize = 1_000; +const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; +const MAX_OPERATORS: usize = 100_000; +const MAX_TEXT_OBJECTS: usize = 4_096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AutoTagResult { + pub pages: usize, + pub paragraphs: usize, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum AutoTagError { + #[error("the document already has a structure tree; review its existing tags instead")] + AlreadyTagged, + #[error("the document has no text objects to tag")] + NoText, + #[error("the document has too many pages to auto-tag in one edit (maximum {MAX_PAGES})")] + TooManyPages, + #[error("page {page}: {reason}")] + Unsupported { page: usize, reason: String }, + #[error("the PDF has no document catalog")] + NoCatalog, + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + +struct Plan { + page: ObjRef, + ops: Vec, + spans: Vec<(usize, usize)>, +} + +fn unsupported(page: usize, reason: impl Into) -> AutoTagError { + AutoTagError::Unsupported { page: page + 1, reason: reason.into() } +} + +fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result, AutoTagError> { + let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) }; + let resolved = doc.resolve(contents); + let pieces: Vec = match &*resolved { + Object::Array(items) => items.clone(), + Object::Stream(_) => vec![contents.clone()], + _ => return Err(unsupported(index, "contents are not a stream or array of streams")), + }; + let mut all = Vec::new(); + for item in pieces { + let value = doc.resolve(&item); + let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) }; + let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1)); + if remaining == 0 { + return Err(unsupported(index, "decoded content exceeds 16 MiB")); + } + let chain = stream.filters(); + let decoded = if chain.is_empty() { + if stream.raw.len() > remaining { + return Err(unsupported(index, "decoded content exceeds 16 MiB")); + } + stream.raw.to_vec() + } else { + pdfcraft_filters::decode(&chain, &stream.raw, remaining) + .map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))? + }; + all.extend_from_slice(&decoded); + all.push(b'\n'); + } + Ok(all) +} + +fn harmless_outside(op: &[u8]) -> bool { + matches!( + op, + b"q" | b"Q" + | b"cm" + | b"w" + | b"J" + | b"j" + | b"M" + | b"d" + | b"ri" + | b"i" + | b"gs" + | b"G" + | b"g" + | b"RG" + | b"rg" + | b"K" + | b"k" + | b"CS" + | b"cs" + | b"SC" + | b"sc" + | b"SCN" + | b"scn" + ) +} + +fn harmless_inside(op: &[u8]) -> bool { + matches!( + op, + b"Tf" + | b"Tc" + | b"Tw" + | b"Tz" + | b"TL" + | b"Tr" + | b"Ts" + | b"Td" + | b"TD" + | b"Tm" + | b"T*" + | b"G" + | b"g" + | b"RG" + | b"rg" + | b"K" + | b"k" + | b"CS" + | b"cs" + | b"SC" + | b"sc" + | b"SCN" + | b"scn" + ) +} + +fn plan(doc: &Document) -> Result, AutoTagError> { + let root = doc.root().ok_or(AutoTagError::NoCatalog)?; + let catalog = doc.get(root); + let catalog = catalog.as_dict().ok_or(AutoTagError::NoCatalog)?; + if catalog.contains(b"StructTreeRoot") { + return Err(AutoTagError::AlreadyTagged); + } + if catalog.contains(b"AcroForm") { + return Err(unsupported(0, "form fields need their own tags; this text-only action cannot tag them")); + } + let pages = pdfcraft_model::pages(doc); + if pages.len() > MAX_PAGES { + return Err(AutoTagError::TooManyPages); + } + let mut out = Vec::with_capacity(pages.len()); + let mut total = 0usize; + for (index, page) in pages.iter().enumerate() { + if page.dict.contains(b"StructParents") { + return Err(unsupported(index, "page already has a structure parent key")); + } + if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) { + Object::Array(items) => !items.is_empty(), + _ => true, + }) { + return Err(unsupported(index, "annotations need separate structure tags")); + } + let bytes = content_bytes(doc, page, index)?; + let parsed = parse(&bytes); + if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { + return Err(unsupported(index, "content has malformed or too many operators")); + } + let mut start = None; + let mut has_text = false; + let mut spans = Vec::new(); + for (at, op) in parsed.ops.iter().enumerate() { + match op.op.as_slice() { + b"BT" if start.is_none() => { + start = Some(at); + has_text = false; + } + b"ET" => { + let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; + if has_text { + spans.push((first, at)); + if spans.len() > MAX_TEXT_OBJECTS { + return Err(unsupported(index, "too many text objects (maximum 4096)")); + } + } + } + b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, + name if start.is_some() && harmless_inside(name) => {} + name if start.is_none() && harmless_outside(name) => {} + _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), + } + } + if start.is_some() { + return Err(unsupported(index, "unterminated BT text object")); + } + total = total.saturating_add(spans.len()); + out.push(Plan { page: page.obj, ops: parsed.ops, spans }); + } + if total == 0 { + return Err(AutoTagError::NoText); + } + Ok(out) +} + +/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree, +/// parent tree and marked-content IDs are written together. Mixed content is refused before any +/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. +pub fn autotag_text(doc: &mut Document) -> Result { + let plans = plan(doc)?; + let mut next = doc.clone(); + let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; + let root = next.add(Object::Null); + let document = next.add(Object::Null); + let mut children = Vec::new(); + let mut numbers = Vec::new(); + let mut tagged_pages = 0usize; + let mut paragraphs = 0usize; + for page in plans { + if page.spans.is_empty() { + continue; + } + let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; + let mut parents = Vec::with_capacity(page.spans.len()); + let mut starts = std::collections::HashMap::new(); + let mut ends = std::collections::HashSet::new(); + for (mcid, (first, last)) in page.spans.iter().enumerate() { + let mut element = Dict::new(); + element.set(b"Type".to_vec(), Object::name("StructElem")); + element.set(b"S".to_vec(), Object::name("P")); + element.set(b"P".to_vec(), Object::Ref(document)); + element.set(b"Pg".to_vec(), Object::Ref(page.page)); + element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); + let reference = next.add(element); + parents.push(Object::Ref(reference)); + children.push(Object::Ref(reference)); + starts.insert(*first, mcid); + ends.insert(*last); + paragraphs = paragraphs.saturating_add(1); + } + let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2))); + for (at, op) in page.ops.into_iter().enumerate() { + if let Some(mcid) = starts.get(&at) { + let mut properties = Dict::new(); + properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); + marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); + } + marked.push(op); + if ends.contains(&at) { + marked.push(Op::new("EMC", Vec::new())); + } + } + let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked))); + next.update_dict(page.page, |d| { + d.set(b"Contents".to_vec(), Object::Ref(stream)); + d.set(b"StructParents".to_vec(), Object::Int(key)); + })?; + numbers.push(Object::Int(key)); + numbers.push(Object::Array(parents)); + tagged_pages += 1; + } + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(numbers)); + let parent_tree = next.add(tree); + let mut doc_element = Dict::new(); + doc_element.set(b"Type".to_vec(), Object::name("StructElem")); + doc_element.set(b"S".to_vec(), Object::name("Document")); + doc_element.set(b"P".to_vec(), Object::Ref(root)); + doc_element.set(b"K".to_vec(), Object::Array(children)); + next.set(document, doc_element); + let mut structure = Dict::new(); + structure.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + structure.set(b"K".to_vec(), Object::Ref(document)); + structure.set(b"ParentTree".to_vec(), Object::Ref(parent_tree)); + structure.set(b"ParentTreeNextKey".to_vec(), Object::Int(i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?)); + next.set(root, structure); + let mark_info = next.get(catalog).as_dict().and_then(|d| d.get(b"MarkInfo")).map(|m| next.resolve(m)).and_then(|m| m.as_dict().cloned()); + let mut mark_info = mark_info.unwrap_or_default(); + mark_info.set(b"Marked".to_vec(), Object::Bool(true)); + next.update_dict(catalog, |d| { + d.set(b"StructTreeRoot".to_vec(), Object::Ref(root)); + d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info)); + })?; + *doc = next; + Ok(AutoTagResult { pages: tagged_pages, paragraphs }) +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 983bd1afa8..81fbade91d 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -13,6 +13,7 @@ use std::collections::{BTreeSet, HashMap}; use pdfcraft_cos::Document; mod alt; +mod autotag; mod content; mod headings; mod report; @@ -20,6 +21,7 @@ mod structure; mod tags; pub use alt::{AltError, Figure, figures, mark_decorative, set_alt}; +pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use headings::{Heading, headings}; pub use report::{REPORT_WORDS, report_html}; pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags}; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index ab201b0f75..e406569a8c 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::Document; +use pdfcraft_cos::{Document, Object}; use super::*; @@ -35,6 +35,67 @@ fn failed(r: &Report) -> Vec { const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; +#[test] +fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("BT /F1 18 Tf 20 160 Td (Heading) Tj ET BT /F1 12 Tf 20 120 Td (Body) Tj ET"), + FONT.into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 120 Td (Second page) Tj ET"), + ], + "", + ); + let result = autotag_text(&mut doc).unwrap(); + assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 }); + let catalog = doc.get(doc.root().unwrap()); + let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + let tree = doc.get(root); + let parent_tree = tree.as_dict().unwrap().reference(b"ParentTree").unwrap(); + let numbers = doc.get(parent_tree); + let entries = numbers.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!(entries[0].as_int(), Some(0)); + assert_eq!(entries[2].as_int(), Some(1)); + assert_eq!(entries[1].as_array().unwrap().len(), 2); + assert_eq!(entries[3].as_array().unwrap().len(), 1); + for page in pdfcraft_model::pages(&doc) { + let content = page.dict.get(b"Contents").unwrap(); + let stream = doc.resolve(content); + let Object::Stream(stream) = &*stream else { panic!("marked page content") }; + let text = String::from_utf8(stream.decoded_strict().unwrap()).unwrap(); + assert!(text.contains("/P <> BDC"), "{text}"); + assert!(text.contains("EMC"), "{text}"); + } + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(tags(&reopened).len(), 4); + assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed); +} + +#[test] +fn autotag_text_refuses_paint_and_does_not_modify_the_document() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"), + FONT.into(), + ], + "", + ); + assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. }))); + assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot")); + assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged)); +} + #[test] fn an_untagged_document_fails_the_document_rules() { let doc = pdf( diff --git a/crates/annot/README.md b/crates/annot/README.md index 67285f8d4f..8ba3ca8c4e 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -47,10 +47,10 @@ deterministic; the engine supplies them. never paint a second icon. - Resizing a stamp changes its `/Rect` and preserves its original appearance and resources; viewers scale that appearance into the new rectangle. Locked stamps refuse the edit. -- Natural-size image stamps placed by the engine counterrotate their appearance on rotated - pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures - and PDF-page stamps still use their existing placement behavior; page-rotation support for - those paths is not complete. +- Picture stamps (a natural-size or explicit-rectangle image, a PDF page, or an image + signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so + the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps + that appearance matrix. The rotation is the page's `/Rotate` at placement. ## Not yet diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 8aeaa1b358..a5394038d0 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -264,6 +264,11 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 { d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 } +/// The page `/Rotate` a rubber stamp's label is turned back by (`/PCStampRotate`). +fn stamp_turn(d: &Dict) -> i64 { + d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The /// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's @@ -632,7 +637,26 @@ pub fn build(d: &Dict) -> Option { } let kind = crate::StampKind::from_name(other)?; let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - return Some(stamp(kind, rect, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)); + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res); + if turn != 0 { + // Upright in the stamp's displayed box, then mapped into `/Rect` the way + // the page maps display space into user space. Viewers fit that box to + // `/Rect`; the page rotation then shows the label the right way up. + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + return Some(stream); } } } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 642969c5d2..a25b35c3b9 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -26,7 +26,8 @@ mod tests; pub enum AnnotError { #[error("the document has no page tree")] NoPageTree, - #[error("page {0} does not exist")] + /// Holds the 0-based index; the message names the 1-based page a user counts (#1550). + #[error("page {} does not exist", .0.saturating_add(1))] NoSuchPage(usize), #[error("there is no comment {index} on page {page}")] NoSuchAnnotation { page: usize, index: usize }, @@ -1090,6 +1091,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R if let Some(b) = by { d.set(b"PCByLine".to_vec(), PdfString::text(b)); } + // The label is drawn for the page as displayed. A later page rotation is a new edit. + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } } Shape::TypedSignature { contours, .. } => { d.set(b"C".to_vec(), rgb(style.color)); @@ -1236,8 +1242,9 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R Ok(index) } -/// Counterrotate a newly placed image stamp's appearance into displayed-page axes. -/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers. +/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. +/// The picture may be an image or a PDF page. The normal appearance's transformed bounding +/// box is fitted to `/Rect` by PDF viewers. pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { let matrix = match rotation { 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], @@ -1247,8 +1254,8 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio }; let (_, r) = annot_ref(doc, page, index)?; let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { - return Err(AnnotError::Invalid("only an image stamp can be oriented".into())); + if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { + return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); } let normal = d .get(b"AP") @@ -1263,6 +1270,21 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio Ok(()) } +/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would +/// replace those characters with `?`, so the edit is refused before the dictionary changes. +fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { + let drawn_elsewhere = + d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); + if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { + return Err(AnnotError::Undrawable(format!( + "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", + c.escape_debug(), + u32::from(c) + ))); + } + Ok(()) +} + /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). /// @@ -1271,19 +1293,19 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio /// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` /// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let drawn_elsewhere = - d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none(); - if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) { - return Err(AnnotError::Undrawable(format!( - "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", - c.escape_debug(), - u32::from(c) - ))); - } + refuse_foreign_text(doc, &annot_dict(doc, r))?; draw_appearance(doc, r) } +/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. +fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { + if let Err(e) = set_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) +} + /// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard /// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, /// cleared when all of it can be drawn) so the edit is reported and flattening won't make that @@ -1300,8 +1322,8 @@ fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { /// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. fn build_appearance(doc: &Document, d: &Dict) -> Option { let mut stream = appearance::build(d)?; - // Image stamp restyling must retain the placement's page-axis correction. - if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { + // Picture-stamp restyling must retain the placement's page-axis correction. + if d.get(b"PCPicture").is_some() { let matrix = d.get(b"AP").and_then(|ap| { let ap = doc.resolve(ap); let normal = doc.resolve(ap.as_dict()?.get(b"N")?); @@ -1782,13 +1804,13 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() }) .collect(); + let before = annot_dict(doc, r); doc.update_dict(r, |d| { d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); d.set(b"Rect".to_vec(), num_array(&rect)); touch(d, meta); })?; - set_appearance(doc, r)?; - Ok(()) + redraw_or_restore(doc, r, before) } /// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's @@ -1800,6 +1822,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); } let d = annot_dict(doc, r); + if !new_text { + refuse_foreign_text(doc, &d)?; + } // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); let (outer, cl) = match callout { @@ -1825,7 +1850,16 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } touch(d, meta); })?; - if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) } + let before = d; + if new_text { + if let Err(e) = draw_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) + } else { + redraw_or_restore(doc, r, before) + } } fn border_width_of(d: &Dict) -> f64 { @@ -1862,7 +1896,9 @@ pub fn set_style( return Err(AnnotError::Invalid("this comment has no line endings to change".into())); } } + refuse_foreign_text(doc, &d)?; let free_text = subtype == "FreeText"; + let before = d; doc.update_dict(r, |d| { if let Some(c) = color { if free_text { @@ -1900,7 +1936,7 @@ pub fn set_style( ensure_ending_room(d); touch(d, meta); })?; - set_appearance(doc, r) + redraw_or_restore(doc, r, before) } /// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, @@ -1926,6 +1962,8 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option if fill.is_some_and(|c| !finite(&c)) { return Err(AnnotError::Invalid("invalid fill colour".into())); } + refuse_foreign_text(doc, &d)?; + let before = d; doc.update_dict(r, |d| { match fill { Some(c) => d.set(b"IC".to_vec(), rgb(c)), @@ -1935,7 +1973,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option } touch(d, meta); })?; - set_appearance(doc, r) + redraw_or_restore(doc, r, before) } /// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c00ed2be79..2ae46a2467 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -187,6 +187,7 @@ fn invalid_geometry_is_rejected() { assert!(matches!(add_annotation(&mut doc, &new(0, s.clone()), &meta("x")), Err(AnnotError::Invalid(_))), "{s:?}"); } assert_eq!(add_annotation(&mut doc, &new(5, Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }), &meta("x")), Err(AnnotError::NoSuchPage(5))); + assert_eq!(AnnotError::NoSuchPage(5).to_string(), "page 6 does not exist", "the message counts pages from 1 (#1550)"); assert!(!doc.is_modified(), "failed adds change nothing"); } @@ -1219,9 +1220,13 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { on_page.push(Object::Ref(r)); set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err(); + let before = list(&doc, 0)[k].clone(); + let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); - assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept"); + assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); + let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); + assert!(matches!(&err, AnnotError::Undrawable(_)), "{err:?}"); + assert_eq!(list(&doc, 0)[k], before, "a refused resize keeps the rectangle"); // New text from the user is taken (and marked), as for any comment. set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap(); assert_eq!(mark(&doc, k), Some('欢')); diff --git a/crates/audit/README.md b/crates/audit/README.md index e13bb30b0d..c8a6a3249f 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -18,8 +18,9 @@ shading and highlights don't either: they're painted before the text, or light a Limits, by design and documented rather than hidden: -- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or - patterns), and a translucent one (`/ca` below 1) is not flagged; +- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose + corners form an axis-aligned rectangle (not other paths, shadings or patterns), and a + translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; - annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index f7737da780..be8afe0a85 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -1,10 +1,11 @@ //! Walk page content streams and collect the opaque filled rectangles they draw. //! //! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque -//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is -//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and -//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed -//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is +//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath +//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts +//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`), +//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading +//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is //! out of scope and documented as such. //! //! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they @@ -45,6 +46,58 @@ struct Gs { alpha: f64, } +/// The path under construction, as far as the audit cares about it. +enum Path { + Empty, + /// A `re` rectangle, in user space. + Rect([f64; 4]), + /// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`. + Lines(Vec<[f64; 2]>, bool), + /// Anything else: curves, a second subpath, a CTM change mid-path. + Other, +} + +impl Path { + /// The rectangle a fill of this path paints, if the path is one. + fn rect(self) -> Option<[f64; 4]> { + match self { + Path::Rect(r) => Some(r), + Path::Lines(pts, _) => lines_rect(pts), + Path::Empty | Path::Other => None, + } + } +} + +/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated +/// start point is dropped first; the remaining four corners must alternate horizontal and +/// vertical edges. +fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> { + // CTM products leave rounding noise, so corners compare with a tolerance (in points). + const EPS: f64 = 1e-6; + let same = |a: f64, b: f64| (a - b).abs() <= EPS; + if pts.len() == 5 && same(pts[0][0], pts[4][0]) && same(pts[0][1], pts[4][1]) { + pts.pop(); + } + let [a, b, c, d] = <[[f64; 2]; 4]>::try_from(pts).ok()?; + let fits = |first_horizontal: bool| { + [(a, b), (b, c), (c, d), (d, a)] + .iter() + .enumerate() + .all(|(i, (p, q))| if (i % 2 == 0) == first_horizontal { same(p[1], q[1]) } else { same(p[0], q[0]) }) + }; + if !(fits(true) || fits(false)) { + return None; + } + let (xs, ys) = ([a[0], b[0], c[0], d[0]], [a[1], b[1], c[1], d[1]]); + let r = [ + xs.into_iter().fold(f64::INFINITY, f64::min), + ys.into_iter().fold(f64::INFINITY, f64::min), + xs.into_iter().fold(f64::NEG_INFINITY, f64::max), + ys.into_iter().fold(f64::NEG_INFINITY, f64::max), + ]; + (r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r) +} + impl Default for Gs { fn default() -> Self { Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 } @@ -79,8 +132,7 @@ impl<'a> Walker<'a> { depth: usize, at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { - // A pending `re` rectangle, in the user space active when it was built. - let mut pending: Option<[f64; 4]> = None; + let mut path = Path::Empty; for (i, op) in ops.iter().enumerate() { self.bump()?; // Paint position of this operator: its own `(piece, joined index)` at page level, @@ -95,7 +147,7 @@ impl<'a> Walker<'a> { if let Some(g) = stack.pop() { *gs = g; } - pending = None; + path = Path::Empty; } b"cm" => { let before = gs.ctm; @@ -105,9 +157,9 @@ impl<'a> Walker<'a> { gs.ctm = m.then(&gs.ctm); } // A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the - // pending rectangle, so keep it for the fill (#1333). - if gs.ctm != before { - pending = None; + // pending path, so keep it for the fill (#1333). + if gs.ctm != before && !matches!(path, Path::Empty) { + path = Path::Other; } } b"g" => { @@ -138,7 +190,7 @@ impl<'a> Walker<'a> { } } b"re" => { - pending = op.nums::<4>().and_then(|[x, y, w, h]| { + let rect = op.nums::<4>().and_then(|[x, y, w, h]| { if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 { let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); (r[2] > r[0] && r[3] > r[1]).then_some(r) @@ -146,18 +198,39 @@ impl<'a> Walker<'a> { None } }); + path = rect.map_or(Path::Other, Path::Rect); + } + b"m" => { + path = match (path, user_point(gs, op)) { + (Path::Empty, Some(p)) => Path::Lines(vec![p], false), + _ => Path::Other, + }; + } + b"l" => { + path = match (path, user_point(gs, op)) { + // Past a repeated start point it can no longer be a rectangle. + (Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => { + pts.push(p); + Path::Lines(pts, false) + } + _ => Path::Other, + }; + } + b"h" => { + path = match path { + Path::Lines(pts, _) => Path::Lines(pts, true), + _ => Path::Other, + }; } b"f" | b"F" | b"f*" | b"B" | b"B*" => { - if let Some(r) = pending.take() + if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect() && gs.alpha >= 1.0 { self.out.push(FilledRect { rect: r, rgb: gs.fill, pos }); } - // Any other path construction clears the pending rectangle. - } - b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => { - pending = None; } + b"S" | b"s" | b"n" => path = Path::Empty, + b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other, b"Do" => { if let Some(name) = op.name(0) { if depth >= MAX_DEPTH { @@ -165,7 +238,7 @@ impl<'a> Walker<'a> { } self.walk_xobject(name, gs, resources, depth, pos)?; } - pending = None; + path = Path::Empty; } _ => {} } @@ -206,6 +279,13 @@ impl<'a> Walker<'a> { } } +/// The `x y` operands of `m`/`l`, mapped to user space. +fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { + let [x, y] = op.nums::<2>().filter(|v| v.iter().all(|n| n.is_finite()))?; + let (x, y) = gs.ctm.apply(x, y); + Some([x, y]) +} + /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { let alpha = resources diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 102f3fb1f0..be98d72842 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -227,3 +227,48 @@ fn self_drawing_form_fails_the_audit() { let result = audit_redactions(&open(b.build())); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn rectangle_drawn_as_a_path_covers_text_like_re() { + // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or + // only by the fill itself), or in a scaled user space: all are faux redactions (#1510). + let covers: [&[u8]; 5] = [ + b"0 g\n100 688 220 32 re f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n", + b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n", + b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n", + b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + } +} + +#[test] +fn non_rectangular_path_is_not_a_cover() { + // Each path's bounding box covers the text, but the shape itself does not hide it all. + let covers: [&[u8]; 5] = [ + // A triangle. + b"0 g\n100 688 m 320 688 l 320 720 l h f\n", + // A triangle, then a line from its start point after `h`: not a fourth corner. + b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n", + // A trapezoid: four corners, one slanted side. + b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n", + // A rectangle rotated 5 degrees about the text by the CTM. + b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n", + // Two subpaths filled together: even-odd leaves the inner one as a hole. + b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n", + ]; + for cover in covers { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec(); + content.extend_from_slice(cover); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover)); + } +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 527fe6641b..60bd9eef4c 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -16,6 +16,7 @@ pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" +tempfile = "3.27" png = "0.18" base64 = { version = "0.22", optional = true } diff --git a/crates/automation/README.md b/crates/automation/README.md index 971125024f..f2e4ec9282 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -28,6 +28,35 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. +## Text search and cache limits + +Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned +text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector +capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the +renderer result instead of copying glyphs. Oversized pages remain usable by the current call but +are not cached; evicted pages can be extracted again. A changed document version invalidates its +cached text on the next text request, and closing a document removes its entries. Cache entries +do not keep an old PDF byte buffer alive. + +This bounds retained text-cache data, not total process memory: active extraction batches, +renderers, document buffers and the requested output have separate lifetimes. Search still reads +growing batches (8 to 128 pages), with one lookahead match to determine continuation. + +`text_find` keeps matches from readable pages when another reached page fails extraction. It +returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and +`warnings_truncated`. At most 100 warnings and 1,024 UTF-8 bytes per error are returned. Failures +are retried on later calls; they are not cached as empty pages. Warnings describe pages visited in +search order, up to the continuation point, not failures in unvisited later pages. + +`complete: true` means the requested search was exhausted with no page failures and no further +matches. `next: {start_page, skip}` exists only when another match would exceed `limit`. A search +that reaches the end with warnings therefore has `complete: false` **without** `next`. Callers +should follow `next` when present and separately inspect warnings. A capped search can return +both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged. + +`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed +semantics; they never silently act on partial text. + ## Shared field properties `form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names): @@ -134,3 +163,24 @@ faces complete absent subset glyphs. `text_edit.ranges` accepts disjoint UTF-8 b undoable and survive saving/reopening. `image_hit` shares the UI's curve and hole hit testing. The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). + +## Opening local GoToR links + +`link_open {doc, page, index, password?}` follows a local PDF link using `link_list` +numbering. Relative file specifications open normally. An **absolute** file specification +requires `allow_absolute: true`, after reviewing the target reported by `link_list`. +Omitting the argument or passing false refuses the request before checking or reading the +target file. This is an intentional compatibility change for callers that previously opened +absolute GoToR targets without explicit consent. Opting in does not bypass `--root`, symlink +confinement, regular-file, PDF-extension, size, or password validation. Consent applies only +to that call, including each password retry; it is never remembered for other links. + +The desktop UI asks Allow/Cancel before following absolute GoToR targets and displays the +complete path. Relative links retain their existing behavior. Named destinations remain +unapplied with a warning. + +## Folder batches + +`doc_batch` runs conversion, OCR and compression over files and optionally recursive +folders. The CLI also exposes it as `pdfcraft-cli batch`. See the +[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1bdae1e6d4..71e96131e2 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -203,6 +203,16 @@ fn review_html(record: &Value) -> Result { } impl Automation { + pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?; + let tags = self.accessibility_tags(a)?; + Ok(json!({ + "tags": tags["tags"], "count": tags["count"], + "note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", + })) + } + pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result { let page = self.page(a)?; let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?; @@ -490,7 +500,7 @@ impl Automation { } impl Automation { - fn ocr_settings(&self, a: &Args) -> Result { + pub(crate) fn ocr_settings(&self, a: &Args) -> Result { let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); if let Some(d) = a.opt_num("dpi")? { settings.dpi = d.clamp(72.0, 600.0) as f32; @@ -652,6 +662,56 @@ impl Automation { })) } + pub(crate) fn print_readiness(&self, a: &Args) -> Result { + let report = self.doc(a)?.print_readiness(); + let findings: Vec = + report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); + let pages: Vec = report + .pages + .iter() + .map(|p| { + json!({ + "page": p.page, + "media_mm": p.media_mm, + "crop_mm": p.crop_mm, + "trim_mm": p.trim_mm, + "bleed_mm": p.bleed_mm, + "trim_set": p.trim_set, + "bleed_set": p.bleed_set, + "bleed_margin_mm": p.bleed_margin_mm, + }) + }) + .collect(); + let images: Vec = report + .images + .iter() + .map(|image| { + json!({ + "page": image.page, + "name": image.name, + "ppi": image.ppi, + "pixels": image.pixels, + "size_mm": image.size_mm, + }) + }) + .collect(); + let pdfa: Vec = report + .pdfa_issues + .iter() + .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) + .collect(); + Ok(json!({ + "text": report.text(), + "findings": findings, + "pages": pages, + "images": images, + "output_intents": report.output_intents, + "fonts_not_embedded": report.fonts_not_embedded, + "pdfa_issues": pdfa, + "resolution_unknown": report.resolution_unknown, + })) + } + pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { let level = self.pdfa_level(a)?; let id = self.doc(a)?.id; diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs new file mode 100644 index 0000000000..99310832fb --- /dev/null +++ b/crates/automation/src/batch.rs @@ -0,0 +1,276 @@ +//! One-shot folder jobs shared by CLI and MCP. Discovery finishes before any output is made. + +use std::collections::{BTreeMap, HashSet}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use pdfcraft_engine::{Session, ocr, optimize}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, failed}; + +const MAX_ENTRIES: usize = 10_000; +const MAX_DEPTH: usize = 64; +const MAX_INPUT: u64 = 512 * 1024 * 1024; + +struct Recipe<'a> { + steps: Vec<&'a str>, + images: optimize::ImageSettings, + ocr: ocr::OcrSettings, +} + +impl<'a> Recipe<'a> { + fn parse(auto: &Automation, args: &'a Args) -> Result { + let steps = args.strs("steps")?; + if steps.is_empty() || steps.len() > 3 { + return Err(Args::wrong("steps", "one to three unique steps: convert, ocr, optimize")); + } + let mut seen = HashSet::new(); + for (i, step) in steps.iter().enumerate() { + if !matches!(*step, "convert" | "ocr" | "optimize") || !seen.insert(*step) || (*step == "convert" && i != 0) { + return Err(Args::wrong("steps", "unique convert/ocr/optimize steps, with convert first when present")); + } + } + for (keys, step) in [(&["ppi", "quality"][..], "optimize"), (&["dpi", "language", "skip_text_pages"][..], "ocr")] { + if !steps.contains(&step) && keys.iter().any(|k| args.get(k).is_some()) { + return Err(failed(format!("{} settings need the {step} step", keys.join("/")))); + } + } + let mut images = optimize::ImageSettings::REDUCE; + if let Some(ppi) = args.opt_num("ppi")? { + if !(9.0..=1600.0).contains(&ppi) { + return Err(Args::wrong("ppi", "9 to 1600")); + } + images.target_ppi = ppi; + images.above_ppi = ppi * 1.5; + } + if let Some(q) = args.opt_int("quality")? { + if !(1..=100).contains(&q) { + return Err(Args::wrong("quality", "1 to 100")); + } + images.compression = optimize::Compression::Jpeg(q as u8); + } + if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { + return Err(Args::wrong("dpi", "72 to 600")); + } + Ok(Self { steps, images, ocr: auto.ocr_settings(args)? }) + } + + fn accepts(&self, path: &Path) -> bool { + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); + extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str())) + } + + fn process(&self, path: &Path) -> Result<(Arc>, Vec)> { + // A fresh session per file bounds retained document state and never touches caller tabs. + let mut session = Session::new(); + let mut input = Vec::new(); + std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?; + if input.len() as u64 > MAX_INPUT { + return Err(failed("input exceeds 512 MiB")); + } + let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?; + let mut bytes = Arc::new(input); + let mut log = Vec::new(); + for step in &self.steps { + let before = bytes.len(); + match *step { + "convert" => bytes = session.convert_to_pdf(name, &bytes)?.1, + "ocr" => { + let engine = ocr::engine().map_err(failed)?; + let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &engine, |_, _| true).map_err(failed)?; + log.push( + json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}), + ); + bytes = result.bytes; + continue; + } + "optimize" => { + let id = session.open(name, None, bytes.clone(), None).map_err(failed)?; + let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() }; + let (candidate, _) = session.optimized_bytes(id, &settings, &[])?; + session.close(id); + if candidate.len() < bytes.len() { + bytes = candidate; + } + } + _ => return Err(failed("unknown batch step")), + } + log.push(json!({"step":step,"bytes_before":before,"bytes_after":bytes.len()})); + } + Ok((bytes, log)) + } +} + +// Windows junctions are reparse points too; do not descend into them as ordinary folders. +fn linked(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +/// Canonicalize the existing ancestor, including when the requested output does not exist yet. +fn absolute(path: &Path) -> Result { + let mut ancestor = path; + let mut tail = Vec::new(); + while !ancestor.exists() { + if ancestor.symlink_metadata().is_ok() { + return Err(failed("broken link in batch path")); + } + tail.push(ancestor.file_name().ok_or_else(|| failed("invalid batch path"))?); + ancestor = ancestor.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + } + let mut path = ancestor.canonicalize().map_err(failed)?; + path.extend(tail.iter().rev()); + crate::lexical(&path).ok_or_else(|| failed("invalid batch path")) +} + +struct Discovery { + files: BTreeMap, + skipped: Vec, + seen: HashSet, + visited: usize, +} + +impl Discovery { + fn walk(&mut self, path: &Path, relative: &Path, out: &Path, recursive: bool, depth: usize) -> Result<()> { + self.visited += 1; + if self.visited > MAX_ENTRIES || depth > MAX_DEPTH { + return Err(failed("batch discovery exceeds 10000 entries or 64 folder levels")); + } + if depth > 0 && path.starts_with(out) { + return Ok(()); + } + let metadata = path.symlink_metadata().map_err(|e| failed(format!("{}: {e}", path.display())))?; + if linked(&metadata) || (!metadata.is_file() && !metadata.is_dir()) { + self.skipped.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":"link or special file"})); + } else if (metadata.is_file() || depth == 0 || recursive) && self.seen.insert(path.to_path_buf()) { + if metadata.is_file() { + self.files.insert(path.to_path_buf(), relative.to_path_buf()); + } else if depth == 0 || recursive { + // Bound the list before sorting it; never collect an unbounded folder listing. + let mut entries = Vec::new(); + for entry in std::fs::read_dir(path).map_err(failed)? { + if entries.len() >= MAX_ENTRIES.saturating_sub(self.visited) { + return Err(failed("batch discovery exceeds 10000 entries")); + } + entries.push(entry.map_err(failed)?.path()); + } + entries.sort(); + for child in entries { + let name = child.file_name().ok_or_else(|| failed("entry has no name"))?; + self.walk(&child, &relative.join(name), out, recursive, depth + 1)?; + } + } + } + Ok(()) + } +} + +impl Automation { + pub(crate) fn doc_batch(&self, args: &Args) -> Result { + let recipe = Recipe::parse(self, args)?; + let recursive = args.opt_bool("recursive")?.unwrap_or(false); + let stop = args.opt_bool("stop_on_error")?.unwrap_or(false); + let paths = args.strs("paths")?; + if paths.is_empty() || paths.len() > 1000 { + return Err(Args::wrong("paths", "1 to 1000 files or folders")); + } + let out = absolute(&self.resolve(args.str("out_dir")?, true)?)?; + let mut roots = Vec::new(); + for path in paths { + let resolved = self.resolve(path, false)?; + let original = self.root.as_ref().map_or_else(|| PathBuf::from(path), |root| root.join(path)); + if linked(&original.symlink_metadata().map_err(failed)?) { + return Err(failed("input roots must be regular files or folders, not links")); + } + let real = absolute(&resolved)?; + if real.is_dir() && real.starts_with(&out) { + return Err(failed("out_dir must not contain an input folder or equal it")); + } + roots.push(real); + } + let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: 0 }; + for root in roots { + let relative = if root.is_dir() { PathBuf::new() } else { PathBuf::from(root.file_name().ok_or_else(|| failed("input has no name"))?) }; + discovery.walk(&root, &relative, &out, recursive, 0)?; + } + let mut files = discovery.skipped; + let (mut completed, mut failed_count) = (0, 0); + let mut stopped = false; + for (path, relative) in discovery.files { + if stopped || !recipe.accepts(&path) { + files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}})); + continue; + } + // Recheck confinement and links before reading a discovered file. + let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?; + if linked(&path.symlink_metadata().map_err(failed)?) || absolute(¤t)? != path { + return Err(failed("input path changed after discovery")); + } + let (bytes, log) = recipe.process(¤t)?; + let target = out.join(relative).with_extension("pdf"); + let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?; + if !target.starts_with(&out) { + return Err(failed("output path leaves out_dir")); + } + let output = publish(&target, &bytes)?; + Ok((output, bytes.len(), log)) + })) + .unwrap_or_else(|_| Err(failed("batch conversion panicked; no output was published"))); + match result { + Ok((output, bytes, steps)) => { + completed += 1; + files.push(json!({"path":path.to_string_lossy(),"status":"ok","output":output.to_string_lossy(),"bytes":bytes,"steps":steps})); + } + Err(e) => { + failed_count += 1; + files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()})); + stopped = stop; + } + } + } + let skipped = files.len().saturating_sub(completed + failed_count); + Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files})) + } +} + +/// Publish atomically without replacing any existing name, even if another writer wins a race. +fn publish(target: &Path, bytes: &[u8]) -> Result { + let dir = target.parent().ok_or_else(|| failed("output has no parent"))?; + std::fs::create_dir_all(dir).map_err(failed)?; + let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?; + temp.write_all(bytes).map_err(failed)?; + temp.as_file().sync_all().map_err(failed)?; + let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy(); + for n in 1..=10_000 { + let candidate = if n == 1 { target.to_path_buf() } else { dir.join(format!("{stem} ({n}).pdf")) }; + match temp.persist_noclobber(&candidate) { + Ok(_) => return Ok(candidate), + Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists || candidate.symlink_metadata().is_ok() => temp = e.file, + Err(e) => return Err(failed(e.error)), + } + } + Err(failed("all 10000 output names are occupied")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_limits_fail_before_reading_another_entry() { + let mut discovery = Discovery { files: BTreeMap::new(), skipped: Vec::new(), seen: HashSet::new(), visited: MAX_ENTRIES }; + assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, 0).unwrap_err().to_string().contains("10000")); + discovery.visited = 0; + assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64")); + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8adc450349..d7eb8a2baa 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -12,6 +12,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod a11y; +mod batch; mod comments; mod content; mod conventions; @@ -24,6 +25,7 @@ mod printing; mod reading; mod redact; mod signing; +mod text_cache; mod tools; use std::collections::HashMap; @@ -95,8 +97,11 @@ const MAX_BOOKMARK_PAGE: usize = 1000; const FIND_FIRST_BATCH: usize = 8; const FIND_MAX_BATCH: usize = 128; -/// Page texts of one document version: (the working bytes, one slot per page). -type TextCache = (Arc>, Vec>>); +use text_cache::TextCache; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; +const FIND_MAX_WARNINGS: usize = 100; +const FIND_MAX_WARNING_BYTES: usize = 1024; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -104,8 +109,8 @@ pub struct Automation { root: Option, /// Synchronous renderers, rebuilt when a document's working bytes change. renderers: HashMap>, PageRenderer)>, - /// Extracted page text per document version (the working bytes it was taken from). - texts: HashMap, + /// Extracted page text, under one memory budget across all open documents. + texts: TextCache, } impl Default for Automation { @@ -116,7 +121,7 @@ impl Default for Automation { impl Automation { pub fn new() -> Self { - Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: HashMap::new() } + Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() } } /// Confine every path the tools read or write to `root` (relative paths resolve inside it). @@ -165,6 +170,7 @@ impl Automation { let a = Args(args); let out = match name { "doc_open" => self.doc_open(&a)?, + "doc_batch" => self.doc_batch(&a)?, "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), "doc_info" => { let id = self.doc(&a)?.id; @@ -717,6 +723,7 @@ impl Automation { "sign_list" => self.sign_list(&a)?, "accessibility_check" => self.a11y_check(&a)?, "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, + "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, @@ -729,6 +736,7 @@ impl Automation { "doc_compare" => self.doc_compare(&a)?, "action_list" => self.action_list()?, "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, "doc_export_office" => self.doc_export_office(&a)?, "pdfa_convert" => self.pdfa_convert(&a)?, "action_run" => self.action_run(&a)?, @@ -1701,23 +1709,47 @@ impl Automation { /// The text of `pages` (0-based), from the cache or extracted in parallel. fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); let password: Option> = doc.password.as_deref().map(Arc::from); - let entry = self.texts.entry(id).or_insert_with(|| (bytes.clone(), Vec::new())); - if !Arc::ptr_eq(&entry.0, &bytes) || entry.1.len() != n { - *entry = (bytes.clone(), vec![None; n]); - } - let mut missing: Vec = pages.iter().copied().filter(|p| entry.1.get(*p).is_some_and(Option::is_none)).collect(); - missing.sort_unstable(); - missing.dedup(); - for (p, text) in extract_parallel(&bytes, password, &missing) { - let text = Arc::new(text.map_err(|e| failed(format!("page {}: {e}", p + 1)))?); - if let Some(slot) = entry.1.get_mut(p) { - *slot = Some(text); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); } } - pages.iter().map(|p| entry.1.get(*p).cloned().flatten().ok_or_else(|| failed(format!("page {} has no text", p + 1)))).collect() + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) } fn text_extract(&mut self, a: &Args) -> Result { @@ -1740,9 +1772,13 @@ impl Automation { /// Matches in document order, extracting page text in growing batches and stopping as soon as /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document - /// (#971). One match past the limit is looked for, so `complete` is true only when no more - /// exist; otherwise `next` holds the arguments that continue the search. + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); let query = a.str("query")?; @@ -1755,13 +1791,28 @@ impl Automation { let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); let mut matches = Vec::new(); let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; let (mut first, mut batch) = (start, FIND_FIRST_BATCH); 'search: while first < n { let end = first.saturating_add(batch).min(n); let pages: Vec = (first..end).collect(); - let texts = self.page_texts(id, &pages)?; - for (p, text) in pages.iter().zip(&texts) { - let skip_here = if *p == start { skip } else { 0 }; + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; // One extra match proves that the caller needs a continuation. Include skipped // matches on the starting page when bounding the temporary per-page result. let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); @@ -1776,7 +1827,8 @@ impl Automation { first = end; batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches }); + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); if let (Some(next), Some(o)) = (next, out.as_object_mut()) { o.insert("next".into(), next); } @@ -2192,12 +2244,12 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> } /// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> Vec<(usize, std::result::Result)> { +fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { let extract = |r: &mut PageRenderer, p: usize| { let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); match out.error { Some(e) => Err(e), - None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()), + None => Ok(out.text.unwrap_or_default()), } }; let config = RenderConfig { password, ..Default::default() }; @@ -2208,7 +2260,7 @@ fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[u return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); } let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: Vec<(usize, std::result::Result)> = std::thread::scope(|s| { + let mut out: PageTexts = std::thread::scope(|s| { let handles: Vec<_> = (0..workers) .map(|_| { s.spawn(|| { @@ -2320,6 +2372,118 @@ mod tests { } } + fn search_document(a: &mut Automation, pages: usize) -> DocId { + a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() + } + + #[test] + fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { + let mut a = Automation::new(); + a.texts = TextCache::new(16 * 1024); + let first = search_document(&mut a, 200); + let second = search_document(&mut a, 200); + for id in [first, second] { + let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" })); + assert_eq!(result["complete"], true); + assert!(a.texts.bytes() <= 16 * 1024); + assert!(a.texts.len() < 200, "an exhaustive search must not retain every page"); + let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 })); + assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches"); + eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes()); + } + assert!(a.texts.get(first, 0).is_none()); + let text = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(text.plain_text(), "Page 1"); + let again = a.page_texts(first, &[0]).unwrap().remove(0); + assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage"); + assert!(a.texts.bytes() <= 16 * 1024); + json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] })); + let edited = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(edited.plain_text(), "Page 2"); + assert!(!Arc::ptr_eq(&text, &edited)); + json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true })); + json(&mut a, "doc_close", json!({ "doc": second.0 })); + assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0)); + } + + #[test] + fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let error = a.page_texts(id, &[0, 3, 1]).unwrap_err(); + assert!(error.to_string().contains("page 4: page is out of range")); + assert!(a.texts.get(id, 0).is_some()); + assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained"); + let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap(); + assert!(results[0].1.is_err(), "a missing worker result must not become an empty page"); + } + + #[test] + fn search_keeps_matches_around_failed_pages_and_continuations() { + let mut a = Automation::new(); + let id = search_document(&mut a, 16); + let mut extract = |bytes: &Arc>, password, pages: &[usize]| { + extract_parallel(bytes, password, pages) + .into_iter() + .map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result })) + .collect() + }; + let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap(); + assert_eq!(all["count"], 14); + assert_eq!(all["complete"], false); + assert_eq!(all["warning_count"], 2); + assert_eq!(all["warnings"][0]["page"], 3); + assert_eq!(all["warnings"][1]["page"], 7); + assert!(all.get("next").is_none()); + assert_eq!(all["matches"][13]["page"], 16); + assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached"); + let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 }); + let mut matches = Vec::new(); + let mut warned = Vec::new(); + loop { + let part = a.text_find_with(&Args(&args), &mut extract).unwrap(); + matches.extend(part["matches"].as_array().unwrap().iter().cloned()); + warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone())); + if !part["warnings"].as_array().unwrap().is_empty() { + assert_eq!(part["complete"], false); + } + let Some(next) = part.get("next") else { break }; + args["start_page"] = next["start_page"].clone(); + args["skip"] = next["skip"].clone(); + } + assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated"); + assert_eq!(warned, vec![json!(3), json!(7)]); + } + + #[test] + fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { + let mut a = Automation::new(); + let id = search_document(&mut a, 200); + let first = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| { + assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end"); + extract_parallel(bytes, password, pages) + }) + .unwrap(); + assert_eq!(first["count"], 1); + assert_eq!(first["warning_count"], 0); + assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 })); + a.texts.remove(&id); + let failures = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| { + pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect() + }) + .unwrap(); + assert_eq!(failures["count"], 0); + assert_eq!(failures["complete"], false); + assert!(failures.get("next").is_none()); + assert_eq!(failures["warning_count"], 200); + assert_eq!(failures["warnings_truncated"], true); + let warnings = failures["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), FIND_MAX_WARNINGS); + assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); + } + #[test] fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); @@ -2328,7 +2492,7 @@ mod tests { std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap(); let mut a = Automation::new().with_root(&dir).unwrap(); let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone(); - let extracted = |a: &Automation| a.texts.values().map(|(_, pages)| pages.iter().filter(|t| t.is_some()).count()).sum::(); + let extracted = |a: &Automation| a.texts.len(); // #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages // aren't extracted or cached. diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index 5375f2b610..c8ef9fc9d4 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -1,7 +1,9 @@ //! Link tools: list, add, edit and delete links; create links from URLs in the text; remove //! all links. Rectangles are points from the top-left of the displayed page. -use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, resolve_remote_document_path}; +use pdfcraft_engine::{ + Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; use pdfcraft_render::{DestView, LinkTarget, RemoteDestination}; use serde_json::{Value, json}; use std::path::Path; @@ -106,6 +108,12 @@ impl Automation { (source.path.clone(), file.clone(), destination.clone(), *new_window) }; let path = resolve_remote_document_path(source_path.as_deref().map(Path::new), &file).map_err(failed)?; + let allow_absolute = a.opt_bool("allow_absolute")?.unwrap_or(false); + if remote_document_path_is_absolute(&file) && !allow_absolute { + return Err(failed( + "this GoToR link names an absolute local path; review the target with link_list and pass allow_absolute: true to open it", + )); + } let path = self.resolve(&path.to_string_lossy(), false)?; let bytes = read_remote_document(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index d114ea6d97..f5fda5be97 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -329,7 +329,7 @@ fn guarded_tool(f: impl FnOnce() -> Result) -> Result) -> Value { - let failed = name == "command_batch" + let failed = matches!(name, "command_batch" | "doc_batch") && content.iter().any(|c| match c { Content::Json(v) => v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0), _ => false, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index b9529dcfae..e9b16fc8ad 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -65,9 +65,10 @@ impl Automation { o => return Err(bad(format!("unknown order {o:?}"))), }; match Layout::multiple(per) { - Layout::Multiple { cols, rows, .. } => Layout::Multiple { + Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple { cols, rows, + per_sheet, order, border: a.opt_bool("border")?.unwrap_or(false), auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true), diff --git a/crates/automation/src/text_cache.rs b/crates/automation/src/text_cache.rs new file mode 100644 index 0000000000..a23259bd69 --- /dev/null +++ b/crates/automation/src/text_cache.rs @@ -0,0 +1,177 @@ +//! Bounded, session-wide page-text reuse. Active tool results own their Arcs independently. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Weak}; + +use pdfcraft_engine::DocId; +use pdfcraft_render::{PageText, TextGlyph}; + +const BYTE_BUDGET: usize = 64 * 1024 * 1024; +// Also bound bookkeeping for documents with thousands of empty pages. +const PAGE_BUDGET: usize = 4096; +type Key = (DocId, usize); + +struct Cached { + version: Weak>, + text: Arc, + bytes: usize, + stamp: u64, +} + +pub(super) struct TextCache { + pages: HashMap, + order: BTreeMap, + clock: u64, + bytes: usize, + budget: usize, +} + +impl Default for TextCache { + fn default() -> Self { + Self::new(BYTE_BUDGET) + } +} + +impl TextCache { + pub(super) fn new(budget: usize) -> Self { + Self { pages: HashMap::new(), order: BTreeMap::new(), clock: 0, bytes: 0, budget } + } + + pub(super) fn prepare(&mut self, id: DocId, bytes: &Arc>) { + let version = Arc::downgrade(bytes); + // At most PAGE_BUDGET entries, independent of the document's page count. Weak pointers + // distinguish revisions without keeping an obsolete PDF byte buffer alive. + let stale: Vec<_> = self.pages.iter().filter(|(key, page)| key.0 == id && !page.version.ptr_eq(&version)).map(|(key, _)| *key).collect(); + for key in stale { + self.remove_page(key); + } + } + + pub(super) fn remove(&mut self, id: &DocId) { + let keys: Vec<_> = self.pages.keys().filter(|key| key.0 == *id).copied().collect(); + for key in keys { + self.remove_page(key); + } + } + + fn remove_page(&mut self, key: Key) { + if let Some(page) = self.pages.remove(&key) { + self.order.remove(&page.stamp); + self.bytes -= page.bytes; + } + } + + fn stamp(&mut self) -> u64 { + if self.clock == u64::MAX { + self.pages.clear(); + self.order.clear(); + self.bytes = 0; + self.clock = 0; + } + self.clock += 1; + self.clock + } + + pub(super) fn get(&mut self, id: DocId, page: usize) -> Option> { + let stamp = self.stamp(); + let entry = self.pages.get_mut(&(id, page))?; + self.order.remove(&entry.stamp); + entry.stamp = stamp; + self.order.insert(stamp, (id, page)); + Some(entry.text.clone()) + } + + pub(super) fn insert(&mut self, id: DocId, page: usize, version: &Arc>, text: Arc) { + let bytes = retained_bytes(&text); + self.remove_page((id, page)); + // An oversized page is usable by this call, but never retained by the cache. + if bytes > self.budget { + return; + } + while self.bytes > self.budget - bytes || self.pages.len() >= PAGE_BUDGET { + let Some((_, key)) = self.order.first_key_value() else { break }; + self.remove_page(*key); + } + let stamp = self.stamp(); + self.pages.insert((id, page), Cached { version: Arc::downgrade(version), text, bytes, stamp }); + self.order.insert(stamp, (id, page)); + self.bytes += bytes; + } + + #[cfg(test)] + pub(super) fn len(&self) -> usize { + self.pages.len() + } + + #[cfg(test)] + pub(super) fn bytes(&self) -> usize { + self.bytes + } +} + +// Estimate owned allocations, including spare capacity; bookkeeping has its own page-count cap. +fn retained_bytes(text: &PageText) -> usize { + let arrays = std::mem::size_of::() + .saturating_add(2 * std::mem::size_of::()) + .saturating_add(text.glyphs.capacity().saturating_mul(std::mem::size_of::())) + .saturating_add(text.line_of.capacity().saturating_mul(std::mem::size_of::())) + .saturating_add(text.space_before.capacity().saturating_mul(std::mem::size_of::())); + text.glyphs.iter().fold(arrays, |sum, glyph| sum.saturating_add(glyph.text.capacity())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn budget_counts_owned_capacity_and_lru_is_shared_across_documents() { + let mut text = PageText::default(); + text.glyphs.reserve(32); + text.line_of.reserve(48); + text.space_before.reserve(64); + let mut word = String::with_capacity(1000); + word.push('a'); + text.glyphs.push(TextGlyph { text: word, rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }); + let cost = retained_bytes(&text); + assert!(cost >= 32 * std::mem::size_of::() + 48 * 4 + 64 + 1000); + let text = Arc::new(text); + let version = Arc::new(vec![1]); + let mut cache = TextCache::new(cost * 2); + cache.insert(DocId(1), 0, &version, text.clone()); + cache.insert(DocId(2), 0, &version, text.clone()); + assert!(Arc::ptr_eq(&cache.get(DocId(1), 0).unwrap(), &text)); + cache.insert(DocId(3), 0, &version, text.clone()); + assert!(cache.get(DocId(2), 0).is_none(), "least recently used page in another document is evicted"); + assert_eq!(cache.bytes, 2 * cost); + cache.insert(DocId(1), 0, &version, text.clone()); + assert_eq!(cache.bytes, 2 * cost, "replacement does not double-count"); + let next_version = Arc::new(vec![1]); + cache.prepare(DocId(1), &next_version); + assert!(cache.get(DocId(1), 0).is_none(), "equal bytes in a new Arc are a new version"); + assert_eq!(cache.bytes, cost); + cache.remove(&DocId(3)); + assert_eq!((cache.bytes, cache.len(), cache.order.len()), (0, 0, 0)); + assert_eq!(Arc::strong_count(&version), 1, "the cache never retains a PDF byte buffer"); + } + + #[test] + fn oversized_pages_bypass_cache_and_empty_pages_have_a_bookkeeping_limit() { + let text = Arc::new(PageText::default()); + let version = Arc::new(vec![]); + let cost = retained_bytes(&text); + let mut tiny = TextCache::new(cost - 1); + tiny.insert(DocId(1), 0, &version, text.clone()); + assert_eq!((tiny.bytes, tiny.len()), (0, 0)); + let mut cache = TextCache::default(); + for page in 0..PAGE_BUDGET + 50 { + cache.insert(DocId(1), page, &version, text.clone()); + } + assert_eq!(cache.len(), PAGE_BUDGET); + assert!(cache.get(DocId(1), 0).is_none()); + assert!(cache.get(DocId(1), PAGE_BUDGET + 49).is_some()); + assert_eq!(cache.bytes, PAGE_BUDGET * cost); + cache.clock = u64::MAX; + cache.insert(DocId(2), 0, &version, text); + assert_eq!((cache.bytes, cache.len()), (cost, 1), "recency counter rollover safely clears old state"); + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 727251a680..cfdeb1c16f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -118,6 +118,17 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ + t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") + .with(schema(json!({ + "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, + "out_dir": {"type":"string"}, + "steps": {"type":"array","items":{"type":"string","enum":["convert","ocr","optimize"]},"minItems":1,"maxItems":3,"uniqueItems":true}, + "recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"}, + "ppi": {"type":"number","minimum":9,"maximum":1600}, + "quality": {"type":"integer","minimum":1,"maximum":100}, + "dpi": {"type":"number","minimum":72,"maximum":600}, + "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} + }), &["paths","out_dir","steps"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), @@ -145,7 +156,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() @@ -170,10 +181,10 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match, in page order, with its page and line rectangles in points (origin top-left). Pages are read only until the limit is reached.") + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.") .ro() .cmd("edit.find") - .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). The search stops once it has this many; `complete` says whether every match was returned, and when it is false `next` holds the start_page and skip that continue the search." }, + .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." }, "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") @@ -550,9 +561,9 @@ pub fn tools() -> Vec { t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied.") + t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( - json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." } }), + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), &["doc", "page", "index"], )), t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") @@ -865,6 +876,8 @@ pub fn tools() -> Vec { t("accessibility_autotag_preview", "Preview automatic tags", "Propose paragraph and heading tags for visible text blocks on one page. Returns page bounds, tentative roles and reasons. Does not change the PDF or claim accessibility compliance. Images, tables, lists, annotations and existing structure require separate review.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), + t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.") + .with(schema(json!({ "doc": doc() }), &["doc"])), t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.") .ro() .cmd("a11y.check") @@ -977,6 +990,9 @@ pub fn tools() -> Vec { .ro() .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"], "description": "Default 2b." } }), &["doc"])), + t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 204d344ff7..0ff3930b4a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -145,6 +145,32 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() { assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P")); } +#[test] +fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { + let dir = workdir("autotag-apply"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] })); + assert_eq!(before["failed"], 2); + let rendered_before = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + + let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc })); + assert_eq!(applied["count"], 3, "Document plus one paragraph per page"); + assert_eq!(applied["tags"][1]["type"], "P"); + let after = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["tagged-pdf", "tagged-content"] })); + assert_eq!(after["passed"], 2); + assert_eq!(a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(), rendered_before); + assert!(matches!(a.call("accessibility_autotag_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_)))); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "tagged.pdf" })); + let saved = std::fs::read(dir.join("tagged.pdf")).unwrap(); + let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap(); + let pages = pdfcraft_model::pages(&reopened); + assert_eq!(pages.len(), 2); + assert!(pages.iter().all(|page| page.dict.contains(b"StructParents"))); + assert!(reopened.get(reopened.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot")); +} + /// A one-page PDF whose form holds one text field named `name`. fn form_pdf(name: &str) -> Vec { let objs: Vec = vec![ @@ -321,6 +347,43 @@ fn arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn arabic_ligature_actual_text_is_one_editable_word_through_save_reopen() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let dir = workdir("arabic-ligature-actual-text"); + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + // Synthetic glyph codes: A س, B م, X lam-alef. No installed Arabic font is needed. + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \ + 1 begincodespacerange <00> endcodespacerange 3 beginbfchar \ + <41> <0633> <42> <0645> <58> \ + endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"ToUnicode".to_vec(), Object::Ref(unicode))).unwrap(); + let content = "BT /F1 24 Tf 20 150 Td (B) Tj /Span <>> BDC (X) Tj EMC (A) Tj ET"; + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + std::fs::write(dir.join("arabic.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + + let mut a = auto(&dir); + let id = ok(&mut a, "doc_open", json!({"path":"arabic.pdf"}))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1})); + assert_eq!(lines["lines"].as_array().unwrap().len(), 1); + assert_eq!(lines["lines"][0]["text"], "سلام"); + assert_eq!(page_text(&mut a, id), ["سلام"]); + assert_eq!(ok(&mut a, "text_find", json!({"doc":id,"query":"سلام"}))["matches"].as_array().unwrap().len(), 1); + let render = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1})), lines); + assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), render); + ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":"Replacement"})); + ok(&mut a, "doc_save", json!({"doc":re,"path":"edited.pdf"})); + let edited = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, edited), ["Replacement"]); + std::fs::remove_dir_all(dir).unwrap(); +} + /// Each of these writes to its required `path` and replaces an existing file there, so the MCP /// annotations must not tell clients the call is read-only or harmless (#130). #[test] @@ -3423,7 +3486,12 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1 })); + let before = a.session().docs().len(); + for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { + assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert_eq!(a.session().docs().len(), before); + } + let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); @@ -4300,6 +4368,29 @@ fn pdfa_through_tools() { assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); } +#[test] +fn print_readiness_through_tools() { + let dir = workdir("print-readiness"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Proof me" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "doc_info", json!({ "doc": doc })); + let r = ok(&mut a, "print_readiness", json!({ "doc": doc })); + assert!(r["text"].as_str().unwrap().contains("verification only"), "{r}"); + assert_eq!(r["pages"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(r["pages"][0]["trim_set"], false, "{r}"); + assert_eq!(r["resolution_unknown"], 0, "{r}"); + let findings = r["findings"].as_array().unwrap(); + // A page without a TrimBox is one informational note for the document, not a bleed warning. + assert!(!findings.iter().any(|f| f["id"] == "page-bleed"), "{r}"); + let trim: Vec<_> = findings.iter().filter(|f| f["id"] == "page-trim").collect(); + assert_eq!(trim.len(), 1, "{r}"); + assert_eq!(trim[0]["severity"], "info"); + assert!(trim[0]["page"].is_null(), "{r}"); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), before, "verification doesn't change the document"); + assert!(a.call("print_readiness", &json!({ "doc": 999 })).is_err()); + assert!(pdfcraft_automation::tools().iter().any(|t| t.name == "print_readiness" && t.read_only)); +} + #[test] fn exporting_to_word_html_and_rtf() { let dir = workdir("office"); @@ -5537,3 +5628,47 @@ fn private_crops_with_nested_state_resize_save_reopen_through_tools() { assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1})), original); } } + +#[test] +fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { + let (base, root) = sandbox("gotor-absolute-consent"); + std::fs::create_dir_all(root.join("folder.pdf")).unwrap(); + std::fs::write(root.join("notes.txt"), fixture(1)).unwrap(); + std::fs::write(root.join("encrypted.pdf"), protected_fixture("secret")).unwrap(); + link_dir(&root, "outside-link", &base.join("outside")); + let mut a = auto(&root); + for (name, target, reason) in [ + ("missing", root.join("missing.pdf"), "missing.pdf"), + ("folder", root.join("folder.pdf"), "not a regular file"), + ("text", root.join("notes.txt"), "not a .pdf file"), + ("outside", base.join("outside/secret.pdf"), "outside"), + ("symlink", root.join("outside-link/secret.pdf"), "outside"), + ] { + let path = format!("{name}-source.pdf"); + std::fs::write(root.join(&path), remote_link_pdf(target.to_str().unwrap(), Some("[0 /Fit]"))).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let before = a.session().docs().len(); + // The absence of a target or a root escape must not be probed before consent. + assert!( + matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("allow_absolute")) + ); + assert!( + matches!(err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true })), ToolError::Failed(m) if m.contains(reason)), + "{name}" + ); + assert_eq!(a.session().docs().len(), before); + } + std::fs::write(root.join("password-source.pdf"), remote_link_pdf(root.join("encrypted.pdf").to_str().unwrap(), Some("[0 /Fit]"))).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); + let before = a.session().docs().len(); + for password in ["", "wrong"] { + assert!(matches!( + err(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": password })), + ToolError::Failed(_) + )); + assert_eq!(a.session().docs().len(), before); + } + let opened = ok(&mut a, "link_open", json!({ "doc": doc, "page": 1, "index": 1, "allow_absolute": true, "password": "secret" })); + assert_eq!(opened["document"]["encrypted"], true); + assert_eq!(opened["destination_applied"], true); +} diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/batch.rs new file mode 100644 index 0000000000..210187ca5e --- /dev/null +++ b/crates/automation/tests/batch.rs @@ -0,0 +1,201 @@ +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::Session; +use serde_json::{Value, json}; + +fn result(auto: &mut Automation, args: Value) -> Value { + match auto.call("doc_batch", &args).unwrap().remove(0) { + Content::Json(value) => value, + _ => panic!("expected a report"), + } +} + +fn pdf() -> Vec { + Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() +} + +#[test] +fn batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("input/sub")).unwrap(); + std::fs::create_dir_all(dir.path().join("input/output/sub")).unwrap(); + std::fs::write(dir.path().join("input/a.txt"), "Hello").unwrap(); + std::fs::write(dir.path().join("input/b.pdf"), b"broken").unwrap(); + std::fs::write(dir.path().join("input/sub/a.txt"), "World").unwrap(); + std::fs::write(dir.path().join("input/notes.bin"), b"ignore").unwrap(); + std::fs::write(dir.path().join("input/output/sub/a.pdf"), b"precious").unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let args = json!({"paths":["input","input/a.txt"],"steps":["convert","optimize"],"out_dir":"input/output","recursive":true}); + let report = result(&mut auto, args.clone()); + assert_eq!(report["completed"], 2, "{report}"); + assert_eq!(report["failed"], 1); + assert_eq!(report["skipped"], 1); + assert_eq!(std::fs::read(dir.path().join("input/output/sub/a.pdf")).unwrap(), b"precious"); + assert_eq!(std::fs::read_to_string(dir.path().join("input/a.txt")).unwrap(), "Hello"); + for name in ["a.pdf", "sub/a (2).pdf"] { + let bytes = std::fs::read(dir.path().join("input/output").join(name)).unwrap(); + let mut session = Session::new(); + let id = session.open(name, None, std::sync::Arc::new(bytes), None).unwrap(); + assert_eq!(session.get(id).unwrap().info.pages.len(), 1); + } + assert!(!dir.path().join("input/output/b.pdf").exists()); + assert!(auto.session().docs().is_empty()); + let again = result(&mut auto, args); + assert_eq!(again["completed"], 2, "previous outputs must not become new inputs: {again}"); + assert!(dir.path().join("input/output/a (2).pdf").exists()); +} + +#[test] +fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("input/sub")).unwrap(); + std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap(); + std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap(); + std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true})); + assert_eq!(report["failed"], 1); + assert_eq!(report["completed"], 0); + assert_eq!(report["skipped"], 1); + assert_eq!(report["stopped"], true); + assert!(!dir.path().join("out").exists()); + let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"})); + assert_eq!(report["completed"], 1); + assert!(!dir.path().join("out/sub").exists()); + assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64); +} + +#[test] +fn invalid_batch_arguments_and_outside_paths_write_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + for change in [ + json!({"steps":["convert","convert"]}), + json!({"steps":["optimize","convert"]}), + json!({"steps":[]}), + json!({"steps":["nope"]}), + json!({"recursive":"yes"}), + json!({"paths":[]}), + json!({"paths":["input","../outside"]}), + json!({"out_dir":"../outside"}), + json!({"out_dir":"input"}), + json!({"out_dir":"."}), + json!({"quality":0}), + json!({"quality":"80"}), + json!({"ppi":2000}), + json!({"steps":["ocr"],"language":"not-a-language"}), + json!({"steps":["ocr"],"dpi":1}), + ] { + let mut args = json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}); + args.as_object_mut().unwrap().extend(change.as_object().unwrap().clone()); + assert!(auto.call("doc_batch", &args).is_err(), "{args}"); + assert!(!dir.path().join("out").exists(), "invalid arguments must be refused before writing"); + } +} + +#[test] +fn explicitly_selected_subfolder_is_processed_without_recursive() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("input/sub")).unwrap(); + std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["convert"],"out_dir":"out"})); + assert_eq!(report["completed"], 1, "{report}"); + assert!(dir.path().join("out/page.pdf").exists()); +} + +#[test] +fn explicit_file_can_write_a_numbered_copy_beside_its_source() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."})); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf()); + assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf()); +} + +#[cfg(unix)] +#[test] +fn non_utf8_discovered_names_are_reported_without_panicking() { + use std::os::unix::ffi::OsStrExt; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("input")).unwrap(); + std::fs::write(dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf")), pdf()).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"})); + assert_eq!(report["failed"], 1); + assert!(report["files"][0]["error"].as_str().unwrap().contains("UTF-8")); +} + +#[cfg(unix)] +#[test] +fn directory_links_are_not_followed_and_output_links_cannot_escape() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("input/sub")).unwrap(); + std::fs::create_dir(dir.path().join("outside")).unwrap(); + std::fs::create_dir(dir.path().join("out")).unwrap(); + std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap(); + std::os::unix::fs::symlink(dir.path().join("input"), dir.path().join("input/loop")).unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), dir.path().join("out/sub")).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out","recursive":true})); + assert_eq!(report["skipped"], 1); + assert_eq!(report["failed"], 1); + assert_eq!(std::fs::read_dir(dir.path().join("outside")).unwrap().count(), 0); +} + +#[test] +fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["ocr","optimize"],"out_dir":"out"})); + if pdfcraft_engine::ocr::available() { + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(report["files"][0]["steps"][0]["skipped_pages"], 1); + assert!(dir.path().join("out/page.pdf").exists()); + } else { + assert_eq!(report["failed"], 1, "{report}"); + assert!(report["files"][0]["error"].as_str().unwrap().contains("model"), "{report}"); + assert!(!dir.path().join("out/page.pdf").exists()); + } +} + +#[test] +fn batch_output_collisions_are_safe_under_concurrent_writers() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap(); + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..2) + .map(|_| { + scope.spawn(|| { + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + barrier.wait(); + result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"out"})) + }) + }) + .collect(); + let reports: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(reports.iter().all(|r| r["completed"] == 1), "{reports:?}"); + assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]); + }); + for name in ["page.pdf", "page (2).pdf"] { + assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), pdf()); + } + assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain"); +} + +#[cfg(feature = "mcp")] +#[test] +fn mcp_batch_errors_keep_the_structured_per_file_report() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("broken.pdf"), b"broken").unwrap(); + let input = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"doc_batch","arguments":{"paths":["broken.pdf"],"steps":["optimize"],"out_dir":"out"}}}).to_string() + "\n"; + let mut out = Vec::new(); + pdfcraft_automation::mcp::McpServer::new(Automation::new().with_root(dir.path()).unwrap()).serve(input.as_bytes(), &mut out).unwrap(); + let report: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(report["result"]["isError"], true, "{report}"); + assert_eq!(report["result"]["structuredContent"]["failed"], 1); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 6938f82b12..0f05661b26 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -105,6 +105,8 @@ pub struct Document { entries: Arc>, trailer: Dict, revisions: Arc>, + /// See [`Self::xref_oddities`]. + xref_oddities: Arc>, repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, @@ -219,6 +221,7 @@ impl Document { entries: Arc::new(BTreeMap::new()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), @@ -282,6 +285,7 @@ impl Document { entries: Arc::new(BTreeMap::new()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), @@ -304,10 +308,11 @@ impl Document { let parsed = doc.read_xref_chain(&mut log); let mut authenticated = false; let ok = match parsed { - Ok((entries, trailer, revisions)) => { + Ok((entries, trailer, revisions, oddities)) => { doc.entries = Arc::new(entries); doc.trailer = trailer; doc.revisions = Arc::new(revisions); + doc.xref_oddities = Arc::new(oddities); // Authenticate before checking the catalog: it may sit in an encrypted object // stream, which reads as garbage until the key is known. if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { @@ -587,6 +592,36 @@ impl Document { self.entries.get(&num).cloned() } + /// Cross-reference entries this reader did not take at face value, as (byte offset of the + /// section, object number): an in-use entry at offset 0, a second and different entry for + /// one object in the same section, a reserved cross-reference stream entry type, or a + /// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside + /// and keeps what it already had for the object. Another reader, the page renderer among + /// them, may follow it instead, so the two can then show different objects for one number. + /// Code that compares revisions (signature validation) must not call such an object + /// unchanged. + pub fn xref_oddities(&self) -> &[(u64, u32)] { + &self.xref_oddities + } + + /// Whether the cross-reference entry of `num` locates that object itself: its `num G obj` + /// header at exactly the recorded offset, or the member with that number at the recorded + /// position of its object stream. When it does not, [`Self::get`] still finds the object by + /// searching, but another reader may resolve the number differently. Ignores unsaved edits. + pub fn xref_entry_is_exact(&self, num: u32) -> bool { + match self.entries.get(&num) { + None | Some(XrefEntry::Free { .. }) => true, + Some(XrefEntry::InFile { offset, .. }) => { + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + // Offsets may count from a header that does not start the file. + usize::try_from(*offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) + } + Some(XrefEntry::InStream { stream, index }) => { + self.objstm(*stream).is_ok_and(|stm| usize::try_from(*index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) + } + } + } + /// Object `num` has an unsaved edit. pub fn is_edited(&self, num: u32) -> bool { self.overlay.contains_key(&num) @@ -951,7 +986,7 @@ impl Document { } #[allow(clippy::type_complexity)] - fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec), CosError> { + fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), 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() })?; @@ -963,6 +998,7 @@ impl Document { let mut entries = BTreeMap::new(); let mut trailer: Option = None; let mut revisions = Vec::new(); + let mut oddities = Vec::new(); let mut visited = HashSet::new(); let mut next = Some(first); while let Some(off) = next.take() { @@ -972,12 +1008,15 @@ impl Document { } // This section's own entries; older sections only fill what newer ones left out. let mut sect = BTreeMap::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) { + // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). + let mut odd = Vec::new(); + let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { Ok(v) => v, Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); - self.read_section(off as usize + self.header_offset, &mut sect)? + odd.clear(); + self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? } Err(e) => { if revisions.is_empty() { @@ -992,17 +1031,23 @@ impl Document { // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm) { + match self.read_section(x as usize, &mut stm, &mut odd) { Ok(_) => { for (num, e) in stm { - if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) { - sect.insert(num, e); + match sect.get(&num) { + None | Some(XrefEntry::Free { .. }) => { + sect.insert(num, e); + } + // The table already places the object somewhere else. + Some(old) if *old != e => odd.push(num), + Some(_) => {} } } } Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")), } } + oddities.extend(odd.into_iter().map(|num| (off, num))); for (num, e) in sect { entries.entry(num).or_insert(e); } @@ -1016,11 +1061,12 @@ impl Document { 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)) + Ok((entries, trailer, revisions, oddities)) } - /// 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> { + /// Read one section at `off`, adding entries not already known (newer sections win). `odd` + /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). + fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1052,11 +1098,17 @@ impl Document { (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. + // Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0 + // is never a real object, so its entry cannot mislead anyone.) if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } continue; } - entries.entry(num).or_insert(entry); + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } } } } @@ -1110,12 +1162,21 @@ impl Document { 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) + // Reserved types are treated as null references (§7.5.8.3). + _ => { + odd.push(num); + continue; + } }; if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } continue; } - entries.entry(num).or_insert(entry); + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } } } Ok((s.dict.clone(), true)) @@ -1263,6 +1324,44 @@ mod tests { out } + /// Entries the reader sets aside are remembered, so a caller comparing revisions can tell + /// that another reader may resolve the object differently. + #[test] + fn cross_reference_entries_that_are_set_aside_are_recorded() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let plain = Document::open(Arc::new(base.clone())).unwrap(); + assert!(plain.xref_oddities().is_empty()); + assert!((1..=3).all(|n| plain.xref_entry_is_exact(n))); + let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap(); + let third = plain.xref_entry(3).unwrap(); + + // A later section: object 3 "in use" at offset 0, object 2 listed twice with different + // offsets, and object 0 at offset 0 (the usual producer slip, which names no object). + let mut bytes = base.clone(); + let section = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]); + // What the reader resolves is what it resolved before: the first entry it kept. + assert_eq!(doc.xref_entry(3), Some(third)); + assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 })); + } + + /// An entry that misses its object still resolves (by searching) but is not exact. + #[test] + fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let text = String::from_utf8_lossy(&base).into_owned(); + let at = text.find("3 0 obj").unwrap(); + let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec())); + assert!(!doc.xref_entry_is_exact(3)); + assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); + } + #[test] fn invalid_xref_object_ranges_are_reconstructed() { // Original in-memory PDF: an intact catalog followed by a damaged xref section. diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 26dcfc46fc..34254bf374 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -431,6 +431,15 @@ fn write_xref_table(out: &mut Vec, offsets: &BTreeMap if include_zero { all.insert(0, (0, (0, 65535, false))); } + // A section is one or more subsections. `xref` followed immediately by `trailer` is not a + // table (qpdf: "xref syntax invalid") and is what an incremental save of a classic-table + // file used to append when nothing was edited. A zero-length subsection names no objects, + // so the free list and every in-use entry stay as they were, while a trailer-only change + // (a missing `/ID`, or a caller edit of the trailer) is still written. + if all.is_empty() { + let _ = writeln!(out, "0 0"); + return; + } let mut i = 0; while i < all.len() { let start = all[i].0; diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 448cf35774..b7514cc053 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -135,6 +135,116 @@ fn full_saves_are_allocated_at_their_exact_size() { } } +/// A one-page classic-table file with no `/ID`, the case that used to append `xref` and then +/// `trailer` with no subsection (#1339). +fn classic_table() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +fn qpdf_check(bytes: &[u8]) -> Option { + let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else { + return None; + }; + assert!(version.status.success()); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap(); + let _ = std::fs::remove_file(&path); + Some(check) +} + +fn page(doc: &Document) -> Dict { + let root = doc.resolve(doc.trailer().get(b"Root").unwrap()); + let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + doc.resolve(kids.first().unwrap()).as_dict().cloned().unwrap() +} + +fn assert_qpdf_clean(bytes: &[u8]) { + let Some(check) = qpdf_check(bytes) else { + eprintln!("qpdf not installed: skipped"); + return; + }; + let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr)); + assert!(check.status.success() && !report.contains("WARNING"), "{report}"); +} + +#[test] +fn unchanged_incremental_save_of_a_classic_table_is_a_valid_xref() { + let input = classic_table(); + let doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(!doc.is_modified()); + assert!(doc.repair_log().is_empty()); + assert_eq!(doc.revisions().len(), 1); + assert!(!doc.revisions()[0].is_stream); + + let unchanged = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(unchanged.starts_with(&input)); + let appended = &unchanged[input.len()..]; + assert!(appended.windows(8).any(|w| w == b"xref\n0 0"), "a section with no entries still needs a subsection"); + assert!(!appended.windows(13).any(|w| w == b"xref\ntrailer")); + + let back = Document::open(Arc::new(unchanged.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert_eq!(page(&back).name(b"Type"), Some(b"Page".as_slice())); + assert!(back.trailer().get(b"ID").is_some(), "a file without an identifier still receives one"); + assert_eq!(hayro_pages(&unchanged), 1); + assert_qpdf_clean(&unchanged); + + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(3, 0), |d| d.set(b"Rotate".to_vec(), Object::Int(90))).unwrap(); + let edited = write_incremental(&edited, &SaveOptions::default()).unwrap(); + let edited_back = Document::open(Arc::new(edited.clone())).unwrap(); + assert!(edited_back.repair_log().is_empty(), "{:?}", edited_back.repair_log()); + assert_eq!(edited_back.get(ObjRef::new(3, 0)).as_dict().unwrap().int(b"Rotate"), Some(90)); + assert_qpdf_clean(&edited); + + let stamped = write_incremental(&doc, &SaveOptions { mod_date: Some("D:20260101000000Z".into()), ..SaveOptions::default() }).unwrap(); + let stamped_back = Document::open(Arc::new(stamped.clone())).unwrap(); + assert!(stamped_back.repair_log().is_empty(), "{:?}", stamped_back.repair_log()); + let info = stamped_back.resolve(stamped_back.trailer().get(b"Info").unwrap()); + assert_eq!( + info.as_dict().unwrap().get(b"ModDate").and_then(|d| d.as_string()).map(|s| s.bytes.as_slice()), + Some(b"D:20260101000000Z".as_slice()) + ); + assert_qpdf_clean(&stamped); +} + +#[test] +fn incremental_save_keeps_a_trailer_only_edit() { + let input = classic_table(); + let mut doc = Document::open(Arc::new(input)).unwrap(); + assert!(!doc.is_modified()); + doc.trailer_mut().set(b"Custom".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"keep me".to_vec()))); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.trailer().get(b"Custom").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"keep me".as_slice())); + assert_eq!(hayro_pages(&saved), 1); + assert_qpdf_clean(&saved); +} + #[test] fn qpdf_accepts_object_stream_output() { let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 7c915ae933..fa649de47d 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-fonts = { workspace = true } png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } +# Already in the graph through image; read directly for CMYK JPEG samples, which image converts to RGB. +zune-jpeg = "0.5" thiserror = { workspace = true } # Already in the graph through lopdf; used only to read plain-text files (decode_text). encoding_rs = "0.8" diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 199dbd8181..d9a96181ab 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,6 +1,6 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with -//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour +//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour //! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are //! reported, never silently left out. @@ -126,13 +126,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - if jpeg && alpha.is_none() && key.is_none() { + // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. + let inverted = s + .dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); + // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image + // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as + // a negative. + let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk)); + if jpeg && alpha.is_none() && key.is_none() && !as_is_negative { return as_is(); } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { - // A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't - // decode, or an indexed one (not valid), is written as is, without its transparency. + // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an + // indexed one (not valid), is written as is, without its transparency. let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() }; space = decoded; (samples, 8) @@ -153,17 +164,10 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } - if jpeg && alpha.is_none() { + if jpeg && alpha.is_none() && !as_is_negative { return as_is(); } - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG - // decoder has already applied an Adobe JPEG's own inversion. - let invert = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))); - let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples + let invert = inverted ^ mask; // a stencil mask paints its 0 samples let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { @@ -200,10 +204,13 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> { (w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h)) } -/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in. +/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, +/// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec, Space)> { - if matches!(space, Space::Indexed(..)) { - return None; + match space { + Space::Indexed(..) => return None, + Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), + _ => {} } let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?; if img.width() as usize != w || img.height() as usize != h { @@ -215,6 +222,34 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec Option> { + use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + let input = dec.input_colorspace()?; + if !matches!(input, ColorSpace::CMYK | ColorSpace::YCCK) || dec.dimensions()? != (w, h) { + return None; + } + dec.set_options(options.jpeg_set_out_colorspace(input)); + let mut px = dec.decode().ok()?; + if px.len() != w.checked_mul(h)?.checked_mul(4)? { + return None; + } + if input == ColorSpace::YCCK { + for p in px.as_chunks_mut::<4>().0 { + let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0); + let ink = |rgb: f32| (255.0 - rgb).round().clamp(0.0, 255.0) as u8; + p[0] = ink(y + 1.402 * cr); + p[1] = ink(y - 0.344_136 * cb - 0.714_136 * cr); + p[2] = ink(y + 1.772 * cb); + } + } + Some(px) +} + /// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values. fn colour_key(doc: &Document, s: &Stream) -> Option> { let m = doc.resolve(s.dict.get(b"Mask")?); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 4d21ea5e97..a42ec84fa8 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -488,12 +488,21 @@ pub enum SourceKind { /// File extensions Create converts to PDF (images and plain text). pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"]; +/// A classic TIFF or a BigTIFF (version 43), in either byte order. +fn is_tiff(bytes: &[u8]) -> bool { + [b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m)) +} + +/// The GIF headers `embed` decodes, so detection and conversion agree. +fn is_gif(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + fn is_image(bytes: &[u8]) -> bool { bytes.starts_with(&[0xFF, 0xD8]) || bytes.starts_with(b"\x89PNG\r\n\x1a\n") - || bytes.starts_with(b"II*\0") - || bytes.starts_with(b"MM\0*") - || bytes.starts_with(b"GIF8") + || is_tiff(bytes) + || is_gif(bytes) // JPEG 2000: a JP2 file or a raw codestream. || bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) @@ -522,11 +531,11 @@ fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { Ok(vec![jpeg(name, bytes)?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) - } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + } else if is_tiff(bytes) { tiff_pages(name, bytes) } else if bytes.starts_with(b"BM") { Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) - } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + } else if is_gif(bytes) { Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?]) } else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) { Ok(vec![jpx(name, bytes)?]) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 88ac114ac0..2d76055ec9 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -408,6 +408,28 @@ fn source_kind_tells_pdfs_images_and_text_apart() { assert_eq!(source_kind("b.bmp", b"BM"), None); assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None); assert_eq!(source_kind("", b""), None); + // Only the GIF versions Create decodes count as images, so detection never promises a + // conversion that then fails. + assert_eq!(source_kind("a.gif", b"GIF8x not a gif"), None); + assert_eq!(source_kind("notes.txt", b"GIF8 is a word here"), Some(SourceKind::Text)); +} + +/// BigTIFF (version 43, used for large scans) is detected and converted like a classic TIFF. +#[test] +fn bigtiff_images_become_pages() { + let mut tif = std::io::Cursor::new(Vec::new()); + { + let mut enc = tiff::encoder::TiffEncoder::new_big(&mut tif).unwrap(); + let mut im = enc.new_image::(4, 2).unwrap(); + im.resolution(tiff::tags::ResolutionUnit::Inch, tiff::encoder::Rational { n: 144, d: 1 }); + im.write_data(&[128; 8]).unwrap(); + } + let tif = tif.into_inner(); + assert!(tif.starts_with(b"II+\0"), "the encoder wrote a BigTIFF"); + assert_eq!(source_kind("scan.tif", &tif), Some(SourceKind::Image)); + let doc = reopen(&from_images(&[("scan.tif".into(), tif)]).unwrap()); + assert_eq!(pages(&doc).len(), 1); + assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 2.0, 1.0], "4×2 px at 144 dpi"); } /// A synthetic ICC profile: a 128-byte header (size, device class, colour space, `acsp`) and @@ -721,3 +743,78 @@ fn damaged_masks_and_sizes_never_panic() { let _ = image_file(&doc, r); } } + +/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an +/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). +fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { + fn put(bits: &mut Vec, value: u32, n: u32) { + bits.extend((0..n).rev().map(|i| value >> i & 1 == 1)); + } + let mut v = vec![0xFF, 0xD8]; + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]); + v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]); + v.extend_from_slice(&[1; 64]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8, 0, 8, 0, 8, 4]); + for id in 1..=4 { + v.extend_from_slice(&[id, 0x11, 0]); + } + // DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0". + v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend(0..12u8); + v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + let mut bits = Vec::new(); + for s in samples { + // With every quantiser 1, a flat block's DC coefficient is 8 × (sample − 128). + let dc = (i32::from(s) - 128) * 8; + let category = 32 - dc.unsigned_abs().leading_zeros(); + put(&mut bits, category, 4); + put(&mut bits, if dc < 0 { (dc + (1 << category) - 1) as u32 } else { dc as u32 }, category); + put(&mut bits, 0, 1); + } + bits.resize(bits.len().div_ceil(8) * 8, true); + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) { + v.push(byte); + if byte == 0xFF { + v.push(0); + } + } + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +#[test] +fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { + // Issue #1640: in a PDF a CMYK JPEG's samples are ink amounts, inverted only by its /Decode, + // while JPEG decoders and image viewers read them inverted (Adobe's convention for files). + // Exported cyan came out red. YCCK samples (Y 179, Cb 171, Cr 1) are cyan ink too. + let inverting = Object::Array([1, 0, 1, 0, 1, 0, 1, 0].map(Object::Int).to_vec()); + for (what, samples, transform, decode, masked) in [ + ("CMYK", [255, 0, 0, 0], 0, None, true), + ("YCCK", [179, 171, 1, 0], 2, None, true), + ("inverted CMYK with an inverting /Decode", [0, 255, 255, 255], 0, Some(&inverting), true), + ("CMYK without a mask", [255, 0, 0, 0], 0, None, false), + ("inverted CMYK with an inverting /Decode, without a mask", [0, 255, 255, 255], 0, Some(&inverting), false), + ] { + let jpeg = flat_four_component_jpeg(samples, transform); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(8, 8, "DeviceCMYK", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + if let Some(decode) = decode { + d.set(b"Decode".to_vec(), decode.clone()); + } + if masked { + let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64]))); + d.set(b"SMask".to_vec(), Object::Ref(m)); + } + Stream::from_raw(d, jpeg.clone()) + }); + let (ext, rgba) = exported_rgba(&doc); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{what}: {:?}", &rgba[..4]); + if decode.is_some() && !masked { + // Inverted like an image file expects: still written as is. + assert_eq!((ext, &extract_images(&doc, &[0], 0).images[0].data), ("jpg", &jpeg), "{what}"); + } + } +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 42f7867162..9f13047b01 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -11,9 +11,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{ - GlyphError, GlyphOutline, ShapedCluster, ShapedScript, helvetica_width, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi, -}; +use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged}; @@ -67,15 +65,30 @@ impl Family { (family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique")) } - /// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths, - /// scaled for Times; Courier is monospaced). - pub fn width(self, s: &str, size: f64, bold: bool) -> f64 { - let w = match self { - Family::Courier => s.chars().count() as f64 * 0.6 * size, - Family::Times => helvetica_width(s, size) * 0.9, - Family::Helvetica => helvetica_width(s, size), - }; - if bold { w * 1.05 } else { w } + /// Advance width of `s` at `size` points, from the published standard-14 metrics. + /// + /// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]), + /// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722). + pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 { + self.std14(bold, italic).text_width(s, size) + } + + /// The standard-14 face this family means at the given style. + pub fn std14(self, bold: bool, italic: bool) -> Std14 { + match (self, bold, italic) { + (Family::Courier, false, false) => Std14::Courier, + (Family::Courier, true, false) => Std14::CourierBold, + (Family::Courier, false, true) => Std14::CourierOblique, + (Family::Courier, true, true) => Std14::CourierBoldOblique, + (Family::Times, false, false) => Std14::TimesRoman, + (Family::Times, true, false) => Std14::TimesBold, + (Family::Times, false, true) => Std14::TimesItalic, + (Family::Times, true, true) => Std14::TimesBoldItalic, + (Family::Helvetica, false, false) => Std14::Helvetica, + (Family::Helvetica, true, false) => Std14::HelveticaBold, + (Family::Helvetica, false, true) => Std14::HelveticaOblique, + (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, + } } } @@ -240,7 +253,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) }; + let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { @@ -271,7 +284,7 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))), Piece::Latin(s) => atoms.extend(s.chars().map(|c| { let text = c.to_string(); - let w = t.family.width(&text, t.size, t.bold); + let w = t.family.width(&text, t.size, t.bold, t.italic); (text, w) })), } @@ -414,7 +427,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let width = pieces .iter() .map(|p| match p { - Piece::Latin(s) => t.family.width(s, t.size, t.bold), + Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic), Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::() * t.size, }) .sum(); @@ -424,7 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 /// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing /// then reports why). fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { - shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold)) + shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic)) } fn arabic_error(e: GlyphError, text: &str) -> EditError { @@ -560,7 +573,7 @@ fn draw_laid_shaped( out.extend(literal(&win_ansi(s))); out.extend_from_slice(b" Tj\n"); current = Some(latin.as_str()); - pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64; + pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64; } Piece::Shaped(clusters) => { for c in clusters { @@ -713,7 +726,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); for (i, line) in lines(t).iter().enumerate() { - let w = t.family.width(line, t.size, t.bold); + let w = t.family.width(line, t.size, t.bold, t.italic); let x = match t.align { Align::Left | Align::Justify => r[0], Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index 4321412ba2..d4ebb73e42 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -8,7 +8,12 @@ use super::*; /// Regression fingerprint for the small-item fixture: drawing bytes, font map and document /// objects, with generated streams decoded so Flate backend selection cannot change the hash. -const SMALL_ITEM_FINGERPRINT: u64 = 4_811_174_621_764_261_623; +// Re-pinned when the standard-14 metrics became exact (F1). The Latin run in the fingerprinted +// item is "Hi " in Helvetica 12pt: the old character-class guess called H 680, i 260 and space +// 260 (14.4pt), where the published metrics are 722, 222 and 278 (14.664pt). The Arabic clusters +// that follow therefore start 0.264pt further right, which is the whole point of F1 -- the glyphs +// now land where the font says they do. Nothing about the Arabic path changed. +const SMALL_ITEM_FINGERPRINT: u64 = 9_200_256_185_091_160_371; /// A cluster that shows one character with the single glyph `id`, at `advance` em. fn cluster(id: u32, advance: f64) -> ShapedCluster { diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 92cc56d366..a3f6c886a2 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -168,8 +168,9 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result { gone.insert(r); @@ -249,33 +258,49 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = page_list(doc) + .iter() + .filter_map(|p| p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())) + .flatten() + .filter_map(|e| e.as_ref()) + .collect(); + prune_fields(doc, &removed_widgets, orphans.then_some(&on_page))?; } Ok(drawn) } -/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. -fn prune_fields(doc: &mut Document, widgets: &HashSet) -> Result<(), EditError> { +/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With +/// `on_page`, also the widgets (fields without kids) that are on no page. +fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<&HashSet>) -> Result<(), EditError> { let Some(root) = doc.root() else { return Ok(()) }; let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); // Returns whether the node survives. - fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, depth: usize) -> bool { + fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, depth: usize) -> bool { if widgets.contains(&r) || depth > 64 { return depth > 64; } let Some(d) = doc.get(r).as_dict().cloned() else { return true }; - let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { return true }; - let survivors: Vec = kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, depth + 1))).cloned().collect(); + let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { + return on_page.is_none_or(|p| p.contains(&r)); + }; + let survivors: Vec = + kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } !survivors.is_empty() } - let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, 0))).cloned().collect(); + let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect(); + if survivors.len() == fields.len() && widgets.is_empty() { + return Ok(()); // only looked for fields on no page, and there were none + } af_dict.set(b"Fields".to_vec(), Object::Array(survivors)); match af_ref { Some(r) => doc.set(r, Object::Dict(af_dict)), diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 701ed11182..d92f284052 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -19,7 +19,8 @@ use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] pub enum EditError { - #[error("page {0} does not exist")] + /// Holds the 0-based index; the message names the 1-based page a user counts (#1550). + #[error("page {} does not exist", .0.saturating_add(1))] NoSuchPage(usize), #[error("{0}")] Invalid(String), diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 616456e8b1..fce7d5e101 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, SaveOptions, write_incremental}; +use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; @@ -179,6 +179,7 @@ fn invalid_requests_change_nothing() { add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false), Err(EditError::NoSuchPage(9)) ); + assert_eq!(EditError::NoSuchPage(9).to_string(), "page 10 does not exist", "the message counts pages from 1 (#1550)"); assert!(!doc.is_modified()); } @@ -214,6 +215,37 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden +/// cloudy square and a cloudy square that already has an appearance keep their previous results. +#[test] +fn flattening_keeps_a_cloudy_square_it_cannot_draw() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; + let case = |extra: &str, drawn: usize, left: bool| { + let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) {extra} >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>", + &annot, + &page, + form, + ]); + let before = streams(&doc, 0); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(p.dict.contains(b"Annots"), left); + if drawn == 0 { + assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment"); + } + }; + case("/BE << /S /C /I 2 >>", 0, true); + case("/BE << /S /S >>", 1, false); + case("/BE << /S /C /I 2 >> /F 2", 0, true); + case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false); +} + /// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must /// generate that appearance and bake it into the page, not delete a comment that shows an icon. #[test] @@ -234,6 +266,34 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #1470: a form field whose widget is on no page (left behind by page extraction or combining) +/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, +/// or other software keeps treating the flattened file as a form. A hidden widget on a page is +/// left alone, as before. +#[test] +fn flattening_fields_drops_the_ones_on_no_page() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>", + "<< /FT /Tx /T (parent) /Kids [9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn"); + let doc = reopen(&doc); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()); + let left: Vec = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect(); + assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays"); +} + /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an /// appearance whose text Helvetica can't draw (another program's), and a comment or form field /// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew @@ -1824,6 +1884,52 @@ fn a_line_drawn_twice_is_replaced_everywhere() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Hello there", "world"]); } +#[test] +fn replacing_a_line_keeps_a_distinct_nearby_line() { + // 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box, + // but the baselines are different lines (#1316). + let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET"; + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + for paragraph in [false, true] { + let mut doc = text_page(src); + assert_eq!(listed(&doc), ["Alpha", "Beta"]); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + if paragraph { + text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&doc), ["ALPHA", "Beta"]); + assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)")); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(listed(&back), ["ALPHA", "Beta"]); + } + } + // The same word on the next line is still a different line. + let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET"); + text::replace_line(&mut same, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&same), ["ALPHA", "Alpha"]); + // Ordinary leading, and a paragraph far enough to group apart, keep the neighbour. + let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET"); + text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap(); + assert_eq!(listed(&spaced), ["ALPHA", "Beta"]); + let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET"); + text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&apart), ["ALPHA", "Beta"]); + // A second copy at the same position is still one replacement. + for paragraph in [false, true] { + let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET"); + if paragraph { + text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap(); + } else { + text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap(); + } + assert_eq!(listed(&duplicate), ["ALPHA"]); + } +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; @@ -2755,14 +2861,17 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() { #[test] fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() { - for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "שלום עולם", "Latin text"] { + for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "PDFمرحبا", "مرحبا", "שלום עולם", "Latin text"] + { let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect(); - let doc = text_page(&format!("/Span <>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC")); - for doc in [&doc, &reopen(&doc)] { - let lines = text::text_lines(doc, 0).unwrap(); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].text, logical, "ActualText is already in typing order"); - assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical); + for drawing in ["(abcd) Tj (efgh) Tj", "(X) Tj"] { + let doc = text_page(&format!("/Span <>> BDC BT /F1 12 Tf 72 700 Td {drawing} ET EMC")); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, logical, "ActualText is already in typing order"); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical); + } } } } @@ -2789,6 +2898,78 @@ fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { } } +/// Synthetic codes A س, B م and X a lam-alef glyph, drawn from left to right. +fn arabic_ligature_page(content: &str, composite: bool) -> Document { + let prefix = if composite { "00" } else { "" }; + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \ + 1 begincodespacerange <{prefix}00> <{prefix}FF> endcodespacerange 4 beginbfchar \ + <{prefix}20> <0020> <{prefix}41> <0633> <{prefix}42> <0645> <{prefix}58> \ + endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + let widths: Vec<_> = (32..=88).map(|c| if c == 32 { "250" } else { "500" }).collect(); + let font = if composite { + "<< /Type /Font /Subtype /Type0 /BaseFont /FakeArabic /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>".into() + } else { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /FakeArabic /FirstChar 32 /LastChar 88 /Widths [{}] /ToUnicode 6 0 R >>", widths.join(" ")) + }; + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + &stream("", content), + &font, + &stream("", &cmap), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /FakeArabic /CIDSystemInfo << /Registry (Synthetic) /Ordering (Identity) /Supplement 0 >> /DW 500 /W [32 [250]] >>", + ]) +} + +#[test] +fn arabic_ligature_actual_text_stays_on_its_line() { + for (ligature, composite) in [("لا", false), ("لأ", false), ("لإ", false), ("لآ", false), ("لَا", false), ("لا", true)] { + let hex: String = ligature.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let (left, glyph, right) = if composite { ("<0042>", "<0058>", "<0041>") } else { ("(B)", "(X)", "(A)") }; + let doc = arabic_ligature_page( + &format!("BT /F1 12 Tf 72 700 Td {left} Tj /Span <>> BDC [{glyph}] TJ EMC {right} Tj ET"), + composite, + ); + let expected = format!("س{ligature}م"); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected.as_str()]); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected); + } + } +} + +#[test] +fn multi_glyph_rtl_actual_text_keeps_its_own_reading_order() { + // Two encoded glyphs are a logical span, even if their replacement looks like a ligature. + for drawing in ["(XX) Tj", "(X) Tj (X) Tj", "[(X) (X)] TJ"] { + let doc = + arabic_ligature_page(&format!("BT /F1 12 Tf 72 700 Td (B) Tj /Span <>> BDC {drawing} EMC (A) Tj ET"), false); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["م", "لا", "س"]); + } +} + +#[test] +fn replacing_word_with_actual_text_ligature_removes_all_its_source_operators() { + let mut doc = arabic_ligature_page( + "BT /F1 12 Tf 72 700 Td (B) Tj /Span <>> BDC /Span BMC (X) Tj EMC EMC (A) Tj ET \ + BT /F1 12 Tf 72 600 Td (AB) Tj ET", + false, + ); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "سلام"); + text::replace_block(&mut doc, 0, 0, "Replacement").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Replacement", "مس"]); + let bytes = page_content_bytes(&doc, 0); + let content = String::from_utf8_lossy(&bytes); + assert!(!content.contains("ActualText") && !content.contains("(X)") && !content.contains("(B)") && !content.contains("(A)"), "{content}"); +} + #[test] fn replacing_arabic_actual_text_removes_the_whole_original_drawing() { let logical = "مرحبا بالعالم"; @@ -3238,3 +3419,27 @@ fn subset_indic_font_without_legacy_name_keeps_logical_text() { assert!(!lines[0].visual_order); assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); } + +/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` +/// …), so it has to be *measured* with that same face. Measuring the upright face while writing +/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. +#[test] +fn an_items_measured_face_is_the_face_it_is_written_as() { + for family in [Family::Helvetica, Family::Times, Family::Courier] { + for bold in [false, true] { + for italic in [false, true] { + let written = family.base_font(bold, italic); + let measured = family.std14(bold, italic); + assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}"); + // ... and the public measure reaches that face, rather than an upright stand-in. + assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}"); + } + } + } + // The faces really do differ, so the assertions above are not all comparing the same number. + let times = |italic| Family::Times.width("Anna", 12.0, false, italic); + assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman"); + // Courier is monospaced in every style: equal widths here are correct, not a collapsed face. + let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic); + assert_eq!(courier(false), courier(true)); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 0270322820..13233f71d4 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -458,6 +458,8 @@ fn fill_color(fill: &[Op]) -> [f64; 3] { struct Shown { op: usize, extra_ops: Vec, + /// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several). + glyph_count: usize, /// Advance in text space, including spacing and TJ adjustments. advance: f64, /// The text matrix where it starts (text space), and the state there and at its `BT`. @@ -553,6 +555,7 @@ fn interpret( let mut items = tail.into_iter(); if let Some(mut first) = items.next() { for shown in items { + first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); first.extra_ops.push(shown.op); first.extra_ops.extend(shown.extra_ops); first.rect = [ @@ -661,10 +664,12 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + let mut glyph_count = 0_usize; for p in &pieces { match p { Object::String(s) => { for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); match m.text_of(code) { Some(t) => text.push_str(&t), None => decodable = false, @@ -706,6 +711,7 @@ fn interpret( out.push(Shown { op: i, extra_ops: Vec::new(), + glyph_count, advance: x_text, tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), state: ts.clone(), @@ -754,7 +760,15 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) - for s in shown { + for mut s in shown { + // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a + // separate logical line. Put its letters into visual order before joining the + // surrounding glyphs; the completed line is recovered to typing order below. + // Multi-glyph and mixed-direction replacements keep their authoritative order. + if s.logical_order && s.glyph_count == 1 && s.text.chars().all(crate::bidi::is_rtl) { + s.text = crate::bidi::to_visual(&s.text); + s.logical_order = false; + } // A line belongs to one stream piece. Within it, PDF producers often put every word // in its own BT/ET: geometry and text state, not object boundaries, define a line. let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); @@ -1471,8 +1485,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn in the same area go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect)); + // line drawn on the same baseline go too, so the replacement is all that shows. + let drops = coincident_ops(&lines, &[&target]); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1493,27 +1507,44 @@ fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) } -/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes -/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would -/// show the old text under the replaced line. Copies overlap a member's rect by more than half of -/// the smaller rect; neighbouring lines share no area. -fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet { +/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible +/// text layer). A surviving copy would show the old text under the replacement. +/// +/// A copy overlaps a target by more than half the smaller box and sits on the same baseline +/// (within the tolerance that joins showing operators onto one line). A neighbouring line can +/// share box area when the leading is tighter than the font's ascent plus descent; its baseline +/// is farther away, so it stays. +fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet { let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); - for l in lines { - let covered = rects.iter().any(|m| { - let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0); - let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0); - let small = area(*m).min(area(l.rect)); - small > 0.0 && ix * iy / small > 0.5 - }); - if covered { - drop.extend(l.ops.iter().copied()); + for line in lines { + let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + if copy { + drop.extend(line.ops.iter().copied()); } } drop } +fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool { + let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0); + let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0); + let small = area(b).min(area(a)); + small > 0.0 && ix * iy / small > 0.5 +} + +/// Perpendicular distance between baselines, in user space. Horizontal text compares height; +/// turned text compares the distance across the writing direction. +fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let angle = target.rotation().to_radians(); + let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; + let dx = line.origin.x - target.origin.x; + let dy = line.origin.baseline - target.origin.baseline; + let offset = dx.mul_add(-sin, dy * cos).abs(); + let size = line.size.min(target.size).max(1.0); + offset.is_finite() && offset < size * 0.3 +} + /// The paragraphs on a page (0-based). pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { let lines = text_lines(doc, page)?; @@ -1527,8 +1558,8 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; - let rects: Vec<_> = target.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).collect(); - let mut drops = coincident_ops(&lines, &rects); + let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let mut drops = coincident_ops(&lines, &targets); drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); @@ -1724,13 +1755,9 @@ pub(crate) fn rewrite_block_with_limit( // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - let std_width = move |s: &str, size: f64| -> f64 { - match family { - crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size, - crate::added::Family::Times => pdfcraft_fonts::helvetica_width(s, size) * 0.92, - crate::added::Family::Helvetica => pdfcraft_fonts::helvetica_width(s, size) * if bold { 1.05 } else { 1.0 }, - } - }; + // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. + let face = family.std14(bold, italic); + let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); @@ -1921,10 +1948,9 @@ pub(crate) fn rewrite_block_with_limit( block_ops.push(Op::new("Q", vec![])); } block_ops.push(Op::new("Q", vec![])); - // The paragraph's operators go, and so does anything drawn in the same area (a fake-bold + // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold // second copy, an invisible text layer): the new text is all that may show. - let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(&lines, &member_rects); + let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); // Text shown earlier in the same text object stays first: the paragraph then goes where it // was, between two halves of the text object, so it keeps its place in reading order. diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 58749afe9c..0213d9040b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -696,8 +696,7 @@ pub(super) fn rewrite_styled_block( let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let rects: Vec<_> = members.iter().map(|l| l.rect).collect(); - let mut drop = coincident_ops(lines, &rects); + let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); let o = &first.origin; let start = first.ops.first().copied().unwrap_or(o.bt_op); diff --git a/crates/engine/src/build_info.rs b/crates/engine/src/build_info.rs new file mode 100644 index 0000000000..778922504b --- /dev/null +++ b/crates/engine/src/build_info.rs @@ -0,0 +1,35 @@ +//! What this build calls itself. A local or patched build sets `PDFCRAFT_BUILD_LABEL` at compile +//! time (`PDFCRAFT_BUILD_LABEL=titlebar cargo build`, or through `packaging/linux/package.sh`), so +//! it can be told from the release of the same version. The version itself stays the release's: +//! the update check compares it with published releases. + +/// The release version from the workspace `Cargo.toml`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The compile-time build label, if any. +pub const LABEL: Option<&str> = option_env!("PDFCRAFT_BUILD_LABEL"); + +/// The version as shown to people: `0.7.1`, or `0.7.1 (titlebar)` for a labelled build. +pub fn version() -> String { + describe(VERSION, LABEL) +} + +fn describe(version: &str, label: Option<&str>) -> String { + match label.map(str::trim).filter(|l| !l.is_empty()) { + Some(label) => format!("{version} ({label})"), + None => version.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::describe; + + #[test] + fn a_label_follows_the_version_and_a_blank_one_is_ignored() { + assert_eq!(describe("0.7.1", None), "0.7.1"); + assert_eq!(describe("0.7.1", Some("")), "0.7.1"); + assert_eq!(describe("0.7.1", Some(" ")), "0.7.1"); + assert_eq!(describe("0.7.1", Some("titlebar")), "0.7.1 (titlebar)"); + } +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d0fc66be69..991c86a9ca 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -457,7 +457,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ sections: &[ToolSection { title: "Accessibility", items: &[ - item("Automatically tag PDF", "tag", "a11y.autotag", Planned("M12")), + item("Automatically tag PDF", "tag", "a11y.autotag", Ready), item("Change reading options", "book-open", "a11y.reading_options", Ready), item("Check for accessibility", "accessibility", "a11y.check", Ready), item("Open accessibility report", "file-text", "a11y.report", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 740aab165b..c3294dc1e2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -321,6 +321,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"), c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), + c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"), c("a11y.report", "Open accessibility report", None, None, Document, "file-text"), c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"), c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"), diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 3f63a5fd9b..e26d9ec60a 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 21] = [ +pub static DATE_LANGUAGES: [DateLanguage; 22] = [ DateLanguage { code: "en", name: "English", @@ -338,6 +338,28 @@ pub static DATE_LANGUAGES: [DateLanguage; 21] = [ days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"], days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"], }, + DateLanguage { + code: "lt", + name: "Lietuvių", + months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"], + months_with_day: Some([ + "sausio", + "vasario", + "kovo", + "balandžio", + "gegužės", + "birželio", + "liepos", + "rugpjūčio", + "rugsėjo", + "spalio", + "lapkričio", + "gruodžio", + ]), + months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."], + days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"], + days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -651,6 +673,9 @@ mod tests { assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November")); assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages"); assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one"); + s.set_date_language(Some("LT")).unwrap(); + assert_eq!(s.date_language(), Some("lt")); + assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio")); s.set_date_language(Some("IT")).unwrap(); assert_eq!(s.date_language(), Some("it")); assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ea9c2b40a..15c0551b14 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod autotag; +pub mod build_info; pub mod camera; pub mod camera_clean; pub mod camera_edges; @@ -27,6 +28,7 @@ pub mod js; pub mod links; pub mod ocr; pub mod optimizer; +pub mod printcheck; mod scan_colours; pub mod signature_image; pub mod signature_scan; @@ -112,7 +114,9 @@ pub fn typed_signature_graphic(text: &str) -> Option { o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) } -pub use links::{MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, resolve_remote_document_path}; +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; /// Comment geometry helpers (text-box line breaking) for frontends. pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; @@ -771,6 +775,74 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = + list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} + fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } @@ -1258,6 +1330,8 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is /// the figure element's object number (from `Document::figures`). SetAltText { @@ -1739,6 +1813,7 @@ impl Edit { Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), Edit::MoveTag { .. } => Label::of("Move object"), @@ -1927,6 +2002,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } | Edit::SetInitialView(_) + | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::SetTagProperty { .. } | Edit::MoveTag { .. } @@ -2091,7 +2167,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let src = mark_source(doc, file)?; let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 }; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; let rect = if natural { let k = (200.0 / sw.max(sh)).min(1.0); let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) }; @@ -2201,6 +2277,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::SetAltText { figure, alt } => { let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; @@ -2608,6 +2687,34 @@ pub struct Session { date_language: Option, } +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -2900,13 +3007,45 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let name = name.into(); - guard(|| self.open_unguarded(name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } } - fn open_unguarded(&mut self, name: String, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); // The renderer authenticates on its own. It cannot use the owner password of R2–R4 // files, so give it the user password that owner authentication recovers. @@ -2925,7 +3064,7 @@ impl Session { // A dynamic XFA form is a shell around an XML template; lay the template out into real // pages and fields so the rest of the engine works on it. The original bytes stay: the // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; // Write `work` as one more revision and reopen it: what the document then is. let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; @@ -2994,7 +3133,7 @@ impl Session { _ => None, }; let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { Some(tpl) => { let mut work = cos.clone(); let pages = info.pages.len(); @@ -3037,24 +3176,22 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - // A script that ran away on open turns the form's scripts off for this document. - d.xfa_template = if ran_away { None } else { xfa_template }; - d.js_output.append(script_output); - if ran_away { - d.xfa_scripts_off = true; - d.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut d.xfa_warnings, &xfa_warnings); + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); } - Ok(id) + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) } /// The last step of opening: build the document record and register it. #[allow(clippy::too_many_arguments)] - fn push_document( - &mut self, + fn build_document( + trust: &Arc, name: String, path: Option, bytes: Arc>, @@ -3063,7 +3200,7 @@ impl Session { render_password: Option, password: Option<&str>, xfa: Option, - ) -> Result { + ) -> Document { let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { @@ -3113,11 +3250,9 @@ impl Session { .unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); - self.next_id += 1; - let id = DocId(self.next_id); - self.docs.push(Document { - id, + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + Document { + id: DocId(0), name, path, bytes, @@ -3134,7 +3269,7 @@ impl Session { added, links, signatures, - trust: self.trust.clone(), + trust: trust.clone(), sig_cache, editor, config, @@ -3145,8 +3280,7 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), - }); - Ok(id) + } } fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { @@ -3203,6 +3337,14 @@ impl Session { // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that // scripts were left alone) and a count in the undo step's label. let renamed_note = |renamed: &[(String, String)]| { diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 47abdf4368..5dd9aff5b5 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -165,6 +165,13 @@ impl std::fmt::Display for RemoteDocumentPathError { } } +/// Whether the original GoToR file specification is absolute on this platform. +/// Check this before resolving relative paths against the source document. This does no I/O; +/// the resolver must still reject URL/network paths and malformed file specifications. +pub fn remote_document_path_is_absolute(raw: &str) -> bool { + Path::new(raw).is_absolute() +} + /// Resolve a PDF remote-Go-To file specification to a local path without opening it. /// Relative paths are based on the source document; an in-memory source has no fallback to CWD. pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { @@ -181,7 +188,7 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< return Err(RemoteDocumentPathError::UrlOrNetwork); } let path = Path::new(raw); - let drive_absolute = path.is_absolute(); + let drive_absolute = remote_document_path_is_absolute(raw); if let Some((scheme, _)) = raw.split_once(':') { let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); @@ -450,6 +457,13 @@ mod tests { assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused"); assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf)); assert_eq!(read(&dir), Err(E::NotPdf)); + #[cfg(unix)] + { + // Sparse: the size guard must reject this without allocating or reading a GiB. + let large = dir.join("large.pdf"); + std::fs::File::create(&large).unwrap().set_len(super::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap(); + assert_eq!(read(&large), Err(E::TooLarge)); + } assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_)))); #[cfg(unix)] { @@ -464,6 +478,26 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn remote_document_absolute_classification_uses_the_original_platform_path() { + use super::remote_document_path_is_absolute as absolute; + for relative in ["target.pdf", "../appendix/target.pdf", "folder/target.pdf"] { + assert!(!absolute(relative), "{relative}"); + } + #[cfg(unix)] + { + assert!(absolute("/manual/target.pdf")); + assert!(!absolute(r"C:\manual\target.pdf")); + } + #[cfg(windows)] + { + assert!(absolute(r"C:\manual\target.pdf")); + assert!(absolute("C:/manual/target.pdf")); + assert!(!absolute(r"C:target.pdf"), "drive-relative is not absolute"); + assert!(!absolute(r"\manual\target.pdf"), "rooted without a drive is not absolute"); + } + } + #[test] fn remote_document_paths_are_local_and_source_relative() { use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; diff --git a/crates/engine/src/printcheck.rs b/crates/engine/src/printcheck.rs new file mode 100644 index 0000000000..744e52d4e4 --- /dev/null +++ b/crates/engine/src/printcheck.rs @@ -0,0 +1,474 @@ +//! Print readiness, verification only. +//! +//! Measures what a press looks at and changes nothing: effective image resolution where each +//! image is drawn, the page boxes (media, crop, trim, bleed), the output intent, fonts that +//! aren't embedded, and the existing PDF/A-2b result. There is no fixup and no generated colour +//! profile. A screen profile such as sRGB is reported as a screen profile, not accepted as a +//! press condition. + +use pdfcraft_cos::Document; +use pdfcraft_edit::PageImage; +use pdfcraft_preflight::{Issue, Level}; + +use crate::Document as OpenDocument; + +/// Below this, ordinary sheet printing cannot hold the picture. Large-format work sometimes can. +const FAIL_PPI: f64 = 150.0; +/// Ordinary close-up print. Between this and [`FAIL_PPI`] the picture is usable but soft. +const WARN_PPI: f64 = 300.0; +/// Less than this past the trim, on the tightest side, is not a normal 3 mm bleed. +const MIN_BLEED_MM: f64 = 2.0; + +/// One thing a printer should see, without the file having been rewritten. +#[derive(Clone, Debug, PartialEq)] +pub struct Finding { + pub id: &'static str, + pub severity: &'static str, + pub message: String, + /// 1-based page, when the finding is about one page. + pub page: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PageBoxes { + pub page: u32, + pub media_mm: [f64; 2], + pub crop_mm: [f64; 2], + pub trim_mm: [f64; 2], + pub bleed_mm: [f64; 2], + pub trim_set: bool, + pub bleed_set: bool, + /// Left, bottom, right, top, in millimetres, from the trim out to the bleed. + pub bleed_margin_mm: [f64; 4], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DrawnImage { + pub page: u32, + pub name: String, + pub ppi: f64, + pub pixels: [u32; 2], + pub size_mm: [f64; 2], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrintReadiness { + pub pages: Vec, + pub images: Vec, + pub output_intents: Vec, + pub fonts_not_embedded: Vec, + pub pdfa_issues: Vec, + pub findings: Vec, + /// Image placements whose resolution couldn't be measured (a degenerate or non-finite + /// placement matrix). They are neither passed nor failed. + pub resolution_unknown: usize, +} + +impl PrintReadiness { + /// A plain-text report. The document is unchanged. + pub fn text(&self) -> String { + let mut out = String::from("Print readiness (verification only; the file was not changed)\n"); + if self.output_intents.is_empty() { + out.push_str("Output intent: none\n"); + } else { + out.push_str(&format!("Output intent: {}\n", self.output_intents.join(", "))); + } + for page in &self.pages { + if !page.trim_set { + out.push_str(&format!("Page {}: media {} mm, no TrimBox\n", page.page, size_mm(page.media_mm))); + continue; + } + out.push_str(&format!( + "Page {}: media {} mm, trim {} mm, bleed margin {} mm{}\n", + page.page, + size_mm(page.media_mm), + size_mm(page.trim_mm), + min_mm(page.bleed_margin_mm), + if page.bleed_set { "" } else { " (no BleedBox)" }, + )); + } + for image in &self.images { + out.push_str(&format!( + "Image {} on page {}: {:.0} ppi, {} × {} px, {} mm\n", + image.name, + image.page, + image.ppi, + image.pixels[0], + image.pixels[1], + size_mm(image.size_mm), + )); + } + if self.resolution_unknown > 0 { + out.push_str(&format!("Images with unknown resolution: {}\n", self.resolution_unknown)); + } + if self.fonts_not_embedded.is_empty() { + out.push_str("Fonts: every referenced font is embedded, or the file has none\n"); + } else { + out.push_str(&format!("Fonts not embedded: {}\n", self.fonts_not_embedded.join(", "))); + } + let level = Level::A2b.label(); + if self.pdfa_issues.is_empty() { + out.push_str(&format!("{level}: no problems found by the structural check\n")); + } else { + out.push_str(&format!("{level}: {} problem{}\n", self.pdfa_issues.len(), if self.pdfa_issues.len() == 1 { "" } else { "s" })); + for issue in &self.pdfa_issues { + match issue.page { + Some(p) => out.push_str(&format!("- page {}: {}\n", p + 1, issue.message)), + None => out.push_str(&format!("- {}\n", issue.message)), + } + } + } + if self.findings.is_empty() { + out.push_str("Print checks: no stop for image resolution or bleed\n"); + } else { + out.push_str("Print checks:\n"); + for finding in &self.findings { + out.push_str(&format!("- [{}] {}\n", finding.severity, finding.message)); + } + } + out.push_str("Not checked: PDF/X, overprint, total area coverage, spot colours, transparency flattening.\n"); + out + } +} + +impl OpenDocument { + /// A print-readiness report. Reads the file and does not edit it. + pub fn print_readiness(&self) -> PrintReadiness { + match &self.editor { + Some(editor) => inspect(&editor.cos), + None => PrintReadiness { + pages: Vec::new(), + images: Vec::new(), + output_intents: Vec::new(), + fonts_not_embedded: Vec::new(), + pdfa_issues: Vec::new(), + resolution_unknown: 0, + findings: vec![Finding { + id: "document", + severity: "info", + message: "The document has no object graph to measure.".into(), + page: None, + }], + }, + } + } +} + +fn inspect(doc: &Document) -> PrintReadiness { + let declared = pdfcraft_preflight::declared(doc); + let pdfa_issues = pdfcraft_preflight::verify(doc, Level::A2b); + let fonts_not_embedded = pdfa_issues + .iter() + .filter(|issue| issue.pattern.contains("isn't embedded") || issue.pattern == "A font isn't embedded") + .map(|issue| issue.value.clone().unwrap_or_else(|| "unnamed font".into())) + .collect(); + let model_pages = pdfcraft_model::pages(doc); + let pages = page_reports(doc, &model_pages); + let (images, resolution_unknown) = image_reports(doc, &model_pages); + let mut findings = Vec::new(); + // Without a TrimBox the trim, the bleed and the crop are the same box, so there is no bleed to + // measure. That is a property of the document, not a fault on every page: report it once. + let untrimmed = pages.iter().filter(|page| !page.trim_set).count(); + if untrimmed > 0 { + findings.push(Finding { + id: "page-trim", + severity: "info", + message: if untrimmed == pages.len() { + "No page has a TrimBox, so bleed can't be measured.".to_string() + } else { + format!("{untrimmed} of {} pages have no TrimBox, so their bleed can't be measured.", pages.len()) + }, + page: None, + }); + } + for page in pages.iter().filter(|page| page.trim_set) { + let tight = page.bleed_margin_mm.into_iter().fold(f64::INFINITY, f64::min); + if tight < MIN_BLEED_MM { + findings.push(Finding { + id: "page-bleed", + severity: "warn", + message: format!( + "Page {} has {:.1} mm of bleed past the trim on its tightest side (a press usually wants about 3 mm). Nothing was added.", + page.page, + tight.max(0.0) + ), + page: Some(page.page), + }); + } + } + for image in &images { + if image.ppi < FAIL_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "fail", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0}. Enlarging it would not add detail.", + image.name, image.page, image.ppi, FAIL_PPI + ), + page: Some(image.page), + }); + } else if image.ppi < WARN_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "warn", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0} for close-up print.", + image.name, image.page, image.ppi, WARN_PPI + ), + page: Some(image.page), + }); + } + } + if declared.output_intents.iter().any(|intent| is_screen_profile(intent)) + && !declared.output_intents.iter().any(|intent| is_press_profile(intent)) + { + findings.push(Finding { + id: "output-intent", + severity: "warn", + message: format!( + "The output intent ({}) is a screen profile. A press condition was not added, because inventing one would retint the file.", + declared.output_intents.join(", ") + ), + page: None, + }); + } + if resolution_unknown > 0 { + findings.push(Finding { + id: "image-ppi-unknown", + severity: "info", + message: format!( + "The resolution of {resolution_unknown} image placement{} couldn't be measured (its placement matrix is degenerate or not finite).", + if resolution_unknown == 1 { "" } else { "s" } + ), + page: None, + }); + } + findings.sort_by(|a, b| a.page.cmp(&b.page).then(a.id.cmp(b.id)).then(a.message.cmp(&b.message))); + PrintReadiness { pages, images, output_intents: declared.output_intents, fonts_not_embedded, pdfa_issues, findings, resolution_unknown } +} + +fn page_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> Vec { + let boxes = pdfcraft_organize::page_boxes(doc).unwrap_or_default(); + pages + .iter() + .enumerate() + .map(|(i, page)| { + let row = boxes.get(i).copied().unwrap_or([[0.0; 4]; 5]); + let trim = row[3]; + let bleed = row[2]; + PageBoxes { + page: (i + 1) as u32, + media_mm: wh_mm(row[0]), + crop_mm: wh_mm(row[1]), + trim_mm: wh_mm(trim), + bleed_mm: wh_mm(bleed), + trim_set: page.dict.contains(b"TrimBox"), + bleed_set: page.dict.contains(b"BleedBox"), + bleed_margin_mm: [mm(trim[0] - bleed[0]), mm(trim[1] - bleed[1]), mm(bleed[2] - trim[2]), mm(bleed[3] - trim[3])], + } + }) + .collect() +} + +/// The images drawn on each page, and how many placements had no measurable resolution. +fn image_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> (Vec, usize) { + let mut out = Vec::new(); + let mut unknown = 0usize; + let mut seen = std::collections::HashSet::new(); + for (i, page) in pages.iter().enumerate() { + let page_no = (i + 1) as u32; + if let Ok(placed) = pdfcraft_edit::page_images(doc, i) { + for image in placed { + if let Some(ppi) = placed_ppi(&image) { + if let Some(object) = image.object { + seen.insert((page_no, object)); + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + let w = (image.rect[2] - image.rect[0]).abs(); + let h = (image.rect[3] - image.rect[1]).abs(); + out.push(DrawnImage { page: page_no, name: image.name, ppi, pixels: [image.width, image.height], size_mm: [mm(w), mm(h)] }); + } + } + } + // Images drawn inside a form XObject are not listed above. Their resolution is the + // smallest use on this page, which is what the optimizer already measures. + for (object, ppi) in pdfcraft_optimize::effective_resolutions(doc, &[page.obj]) { + if seen.contains(&(page_no, object)) { + continue; + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + out.push(DrawnImage { page: page_no, name: format!("object {object}"), ppi, pixels: [0, 0], size_mm: [0.0, 0.0] }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(a.name.cmp(&b.name)).then(a.ppi.total_cmp(&b.ppi))); + (out, unknown) +} + +/// The effective resolution of a raster placement, `None` for Form artwork. Not finite (NaN) +/// when the placement can't be measured: callers report that rather than pass it. +fn placed_ppi(image: &PageImage) -> Option { + if image.is_form { + return None; + } + Some(matrix_ppi(image.matrix, image.width, image.height)) +} + +/// Pixels per inch of a `width` × `height` image drawn through `matrix`, or NaN when the matrix +/// is degenerate or not finite or the image has no pixels. +fn matrix_ppi(matrix: [f64; 6], width: u32, height: u32) -> f64 { + let [a, b, c, d, ..] = matrix; + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if width == 0 || height == 0 || !sx.is_finite() || !sy.is_finite() || sx <= 1e-6 || sy <= 1e-6 { + return f64::NAN; + } + let ppi = (f64::from(width) / (sx / 72.0)).min(f64::from(height) / (sy / 72.0)); + if ppi.is_finite() { ppi } else { f64::NAN } +} + +fn is_screen_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("srgb") || id.contains("adobe rgb") || id.contains("display") +} + +fn is_press_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("cmyk") || id.contains("fogra") || id.contains("coated") || id.contains("swop") || id.contains("gracol") +} + +fn mm(points: f64) -> f64 { + points * 25.4 / 72.0 +} + +fn wh_mm(rect: [f64; 4]) -> [f64; 2] { + [mm((rect[2] - rect[0]).abs()), mm((rect[3] - rect[1]).abs())] +} + +fn size_mm(wh: [f64; 2]) -> String { + format!("{:.1} × {:.1}", wh[0], wh[1]) +} + +fn min_mm(sides: [f64; 4]) -> String { + let n = sides.into_iter().fold(f64::INFINITY, f64::min); + if n.is_finite() { format!("{n:.1}") } else { "0.0".into() } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + fn image(doc: &mut Document, w: u32, h: u32) -> pdfcraft_cos::ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &vec![0; (w as usize) * (h as usize)]))) + } + + fn page(doc: &mut Document, name: &str, object: pdfcraft_cos::ObjRef, content: &str) -> pdfcraft_cos::ObjRef { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(name.as_bytes().to_vec(), Object::Ref(object)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + r + } + + #[test] + fn a_drawn_image_is_measured_where_it_is_placed() { + let mut doc = Document::new_empty(); + // 100 px across 144 pt (2 in) is 50 ppi, not "100 px on a letter page". + let soft = image(&mut doc, 100, 100); + // 600 px across 72 pt (1 in) is 600 ppi. + let sharp = image(&mut doc, 600, 600); + page(&mut doc, "Soft", soft, "q 144 0 0 144 36 600 cm /Soft Do Q"); + let sharp_page = page(&mut doc, "Sharp", sharp, "q 72 0 0 72 36 36 cm /Sharp Do Q"); + doc.update_dict(sharp_page, |d| { + let inset = |n: i64| Object::Array(vec![n.into(), n.into(), (612 - n).into(), (792 - n).into()]); + d.set(b"TrimBox".to_vec(), inset(18)); + d.set(b"BleedBox".to_vec(), inset(0)); + }) + .unwrap(); + + let report = inspect(&doc); + let soft = report.images.iter().find(|image| image.name == "Soft").unwrap(); + let sharp = report.images.iter().find(|image| image.name == "Sharp").unwrap(); + assert!((soft.ppi - 50.0).abs() < 0.5, "{}", soft.ppi); + assert!((sharp.ppi - 600.0).abs() < 0.5, "{}", sharp.ppi); + assert!(report.findings.iter().any(|f| f.id == "image-ppi" && f.severity == "fail" && f.page == Some(1))); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi" && f.page == Some(2))); + // Page 1 has no TrimBox: one document-level note, not a bleed warning per page. + assert!(!report.findings.iter().any(|f| f.id == "page-bleed"), "{:?}", report.findings); + let trim: Vec<_> = report.findings.iter().filter(|f| f.id == "page-trim").collect(); + assert_eq!(trim.len(), 1, "{:?}", report.findings); + assert_eq!((trim[0].severity, trim[0].page), ("info", None)); + assert!(trim[0].message.starts_with("1 of 2 pages"), "{}", trim[0].message); + assert_eq!(report.resolution_unknown, 0); + assert!(report.text().contains("50 ppi")); + assert!(report.text().contains("verification only")); + } + + #[test] + fn untrimmed_pages_get_one_note_and_a_tight_trim_gets_a_warning() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 600, 600); + for _ in 0..3 { + page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + } + let report = inspect(&doc); + let notes: Vec<_> = report.findings.iter().filter(|f| f.id.starts_with("page-")).collect(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert_eq!(notes[0].message, "No page has a TrimBox, so bleed can't be measured."); + + // A TrimBox 1 pt inside the crop, with no BleedBox: 0.35 mm of bleed is a real warning. + let tight = page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + doc.update_dict(tight, |d| d.set(b"TrimBox".to_vec(), Object::Array(vec![1.into(), 1.into(), 611.into(), 791.into()]))).unwrap(); + let report = inspect(&doc); + assert!(report.findings.iter().any(|f| f.id == "page-bleed" && f.severity == "warn" && f.page == Some(4))); + assert_eq!(report.findings.iter().filter(|f| f.id == "page-bleed").count(), 1); + } + + #[test] + fn an_unmeasurable_placement_is_never_passed() { + assert!(matrix_ppi([f64::NAN, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([f64::INFINITY, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([0.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 0, 600).is_nan()); + assert!((matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600) - 600.0).abs() < 1e-9); + + // A placement scaled past f64's range and then multiplied by zero has a NaN matrix. The + // content readers drop it before it is measured; it must never be reported as passing. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 100, 100); + let huge = format!("1{}", "0".repeat(400)); + page(&mut doc, "Im", img, &format!("q {huge} 0 0 {huge} 0 0 cm 0 1 1 0 0 0 cm /Im Do Q")); + let report = inspect(&doc); + assert!(report.images.iter().all(|image| image.ppi.is_finite()), "{:?}", report.images); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi"), "{:?}", report.findings); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0ec09c2f2c..37cafd440a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,20 @@ fn session_with(n: usize) -> (Session, DocId) { (s, id) } +#[test] +fn prepared_open_does_not_mutate_the_session_until_commit() { + fn assert_send() {} + assert_send::(); + + let mut session = Session::new(); + let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares"); + assert!(session.get(DocId(1)).is_none()); + + let id = session.commit_open(prepared); + assert_eq!(id, DocId(1)); + assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3); +} + #[test] fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; @@ -100,6 +114,191 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { } } +/// A blank page turned `degrees`, with one annotation placed from displayed coordinates. +fn rotated_blank(degrees: i64) -> (Session, DocId) { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open_new("drawing.pdf", blank).unwrap(); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + (session, id) +} + +/// The user-space box of a `w` by `h` point rectangle centred at a displayed point. +fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] { + let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)]; + let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect(); + [ + f64::from(mapped[0][0].min(mapped[1][0])), + f64::from(mapped[0][1].min(mapped[1][1])), + f64::from(mapped[0][0].max(mapped[1][0])), + f64::from(mapped[0][1].max(mapped[1][1])), + ] +} + +fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + rendered +} + +fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] { + let offset = ((y * rendered.width + x) * 4) as usize; + rendered.rgba[offset..offset + 4].try_into().unwrap() +} + +/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save. +#[test] +fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { + let (w, h) = StampKind::SignHere.size(); + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let rect = displayed_rect(&info, 100.0, 40.0, w, h); + let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None }; + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Test".into(), + }), + ) + .unwrap(); + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + let doc = session.get(id).unwrap(); + let info = doc.info.pages[0].clone(); + let stamp = doc.info.annotations[0].rect; + let a = info.user_to_view(stamp[0], stamp[1]); + let b = info.user_to_view(stamp[2], stamp[3]); + let (left, top) = (a[0].min(b[0]), a[1].min(b[1])); + let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1])); + let scale = 2.0; + let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32); + // The tag's point is the left edge. The flat end fills the right edge. + let tip = at(left + 4.0, (top + bottom) / 2.0); + let outside = at(left + 4.0, top + 4.0); + let end = at(right - 4.0, top + 4.0); + assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point"); + assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper"); + assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + +/// A one-page PDF whose left half is red and whose right half is white, as displayed. +fn half_red_page() -> Vec { + let content = "1 0 0 rg 0 0 40 40 re f"; + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + 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 offset in offsets { + out.extend_from_slice(format!("{offset: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 quadrant_png() -> Arc> { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + Arc::new(png.into_inner()) +} + +fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) { + assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})"); + } +} + +/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright. +#[test] +fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { + let bytes = quadrant_png(); + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0); + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect, + name: "Original quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + assert_upright_quadrants(&session, id, degrees); + session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap(); + assert_upright_quadrants(&session, id, degrees); + } +} + +/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box. +#[test] +fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { + let bytes = Arc::new(half_red_page()); + for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let at = info.view_to_user(100.0, 100.0).map(f64::from); + let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) }; + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect, + name: "Half red".into(), + file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let left = stamp_pixel(&rendered, 80, 100); + let right = stamp_pixel(&rendered, 120, 100); + assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red"); + assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper"); + } +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; @@ -1508,6 +1707,72 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what +/// the page's /Resources holds. +fn one_line_page(resources: &str, body: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does. +fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text"); + let range = text.find(needle).pop().expect("the page shows the text"); + let info = &doc.info.pages[0]; + let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect(); + let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() }; + let mark = + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); + s.apply(id, mark).unwrap(); +} + +/// Redaction removes text by the positions its own interpreter works out. Here the font and size +/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does +/// not model, so it places no glyph under the mark the user drew over the word as displayed: it +/// used to report success and leave the word in the file. The renderer is asked now, and the edit +/// is refused. +#[test] +fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { + let line = "Public words then SECRET42 and more"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); + let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); + assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); + // Nothing was changed: the mark is still pending and the page still shows the line. + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); + assert_eq!(page_texts(&s, id), [line]); + + // The same line with its font set by `Tf`: the mark is applied and the word is gone. + let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); + let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let shown = page_texts(&s, id).swap_remove(0); + assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); +} + #[test] fn redaction_marks_apply_for_good_and_undo() { let (mut s, id) = session_with(2); @@ -3246,6 +3511,68 @@ fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_ assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into())); } +#[test] +fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() { + // `carried` inside the area and a second `carried` outside it bind to one data node (areas + // are not data scopes). In document order: `setter` writes the inner one, `echo` reads the + // outer one in the same event, and the saved data holds the one value. + let tpl = pdfcraft_xfa::fixtures::leave_template("
5
") + .replace( + r#" "#, + r#" + "#, + ) + .replace( + " \n \n", + r#" + + + +"#, + ); + assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped"); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("shared.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{out:?}"); + let values = |s: &Session, id: DocId, n: &str| { + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::>() + }; + assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value"); + assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in"); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9")); + assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node"); + // The inner field hidden (no widget): the outer widget still takes the node's value, and + // an unrelated edit does not write the old value back over it. + let hidden = Arc::new(pdfcraft_xfa::fixtures::shell( + &tpl.replace(r#""#, + r#"90"#, + ))); + let id = s.open("check.pdf", None, checkbox, None).expect("opens"); + let carried: Vec<(pdfcraft_forms::FieldKind, Vec)> = + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect(); + assert_eq!( + carried, + vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])], + "{carried:?}" + ); +} + #[test] fn dynamic_xfa_forms_run_formcalc_scripts() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d72b49d1e6..d180169677 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -63,4 +63,16 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// The page beneath an added item while it is dragged: `index` counts the page's added + /// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`]. + pub fn added_content_preview(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } } diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 15f617cf80..0f37933afd 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -258,6 +258,8 @@ struct Runner<'a, 'b> { /// Field values to write, by name (the last wins). field_changes: std::collections::BTreeMap>, by_som: Option>, + /// Widget names by the data node they bind to (built with `by_som`). + by_path: Option>>, fields: Option>, /// The live form as last built; dropped when a script changed the form. cached: Option, @@ -289,6 +291,7 @@ impl<'a, 'b> Runner<'a, 'b> { pending: Vec::new(), field_changes: Default::default(), by_som: None, + by_path: None, fields: None, cached: None, session: None, @@ -341,11 +344,26 @@ impl<'a, 'b> Runner<'a, 'b> { fn by_som(&mut self) -> &HashMap { if self.by_som.is_none() { - self.by_som = Some(pdfcraft_xfa::fields_by_som(self.doc)); + let by_som = pdfcraft_xfa::fields_by_som(self.doc); + let mut by_path: HashMap> = HashMap::new(); + for (som, name) in &by_som { + by_path.entry(pdfcraft_xfa::data_path(self.tpl, som)).or_default().push(name.clone()); + } + for names in by_path.values_mut() { + names.sort(); + } + self.by_path = Some(by_path); + self.by_som = Some(by_som); } self.by_som.get_or_insert_with(Default::default) } + /// The widgets bound to the data node at `path`, in name order. + fn widgets_at(&mut self, path: &pdfcraft_xfa::DataPath) -> Vec { + self.by_som(); + self.by_path.as_ref().and_then(|m| m.get(path)).cloned().unwrap_or_default() + } + fn fields(&mut self) -> &mut HashMap { if self.fields.is_none() { self.fields = Some(field_infos(self.doc)); @@ -387,55 +405,63 @@ impl<'a, 'b> Runner<'a, 'b> { self.invalidate(); } - /// Give the object at `som` the value `value` (field `name` when it has a widget). Returns - /// whether it changed. + /// Give the object at `som` the value `value`: in the data, and in every widget bound to + /// that data node (each valued by its own kind: a check box by its on state, a list box by + /// lines). Returns whether anything changed. fn set_value(&mut self, som: &str, value: &str) -> bool { - let path = pdfcraft_xfa::data_path(self.tpl, som); - let name = self.by_som().get(som).cloned(); - let Some(name) = name else { - // No widget (a hidden field, a draw, a row not laid out yet): the data alone. - if self.data.as_ref().and_then(|d| d.text_at(&path)) == Some(value) { - return false; - } - self.value_op(som, path, value.to_string()); - self.changes.values += 1; - return true; - }; - let Some(info) = self.fields().get(&name).cloned() else { return false }; use pdfcraft_forms::FieldKind as K; - let new_value: Vec = match info.kind { - K::CheckBox => { - if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state { - vec![info.on_state.clone()] - } else { - Vec::new() + let path = pdfcraft_xfa::data_path(self.tpl, som); + let names = self.widgets_at(&path); + // The data text: the widget at `som` says how its kind writes the value; without one, + // the first widget bound to the node, else the value itself. + let primary = self.by_som().get(som).cloned().or_else(|| names.first().cloned()); + let mut text = value.to_string(); + let mut changed = false; + for name in &names { + let Some(info) = self.fields().get(name).cloned() else { continue }; + let new_value: Vec = match info.kind { + K::CheckBox => { + if pdfcraft_xfa::data::is_on(value, &info.on_state) || value == info.on_state { + vec![info.on_state.clone()] + } else { + Vec::new() + } } + K::Radio => { + let v = value.trim(); + if v.is_empty() { Vec::new() } else { vec![v.to_string()] } + } + // Read-only fields still take values from scripts (calculated totals are + // read-only); buttons and signatures hold none. + K::PushButton | K::Signature => continue, + K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(), + _ => vec![value.to_string()], + }; + let same = match info.kind { + K::CheckBox => info.value.is_empty() == new_value.is_empty(), + K::Radio => info.value.first() == new_value.first(), + K::List if info.multi => info.value == new_value, + _ => info.value.first().map_or(value.is_empty(), |v| v == value), + }; + let updated = FieldInfo { value: new_value.clone(), ..info }; + if primary.as_deref() == Some(name.as_str()) { + text = data_text(&updated); } - K::Radio => { - let v = value.trim(); - if v.is_empty() { Vec::new() } else { vec![v.to_string()] } + if same { + continue; } - // Read-only fields still take values from scripts (calculated totals are read-only). - K::PushButton | K::Signature => return false, - K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(), - _ => vec![value.to_string()], - }; - let same = match info.kind { - K::CheckBox => info.value.is_empty() == new_value.is_empty(), - K::Radio => info.value.first() == new_value.first(), - K::List if info.multi => info.value == new_value, - _ => info.value.first().map_or(value.is_empty(), |v| v == value), - }; - if same { - return false; + self.fields().insert(name.clone(), updated); + self.field_changes.insert(name.clone(), new_value); + changed = true; } - let updated = FieldInfo { value: new_value.clone(), ..info }; - let text = data_text(&updated); - self.fields().insert(name.clone(), updated); - self.field_changes.insert(name, new_value); - self.value_op(som, path, text); - self.changes.values += 1; - true + if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { + self.value_op(som, path, text); + changed = true; + } + if changed { + self.changes.values += 1; + } + changed } /// Write what the scripts changed so far: the data as one rewrite, the field values, the @@ -489,6 +515,7 @@ impl<'a, 'b> Runner<'a, 'b> { crate::xfa_redraw(self.doc)?; self.console(crate::undrawn_field_warnings(self.doc)); self.by_som = None; + self.by_path = None; self.fields = None; self.invalidate(); Ok(()) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 19d0d1daf4..622c609f69 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,7 +1,9 @@ //! pdfcraft-fonts — font metrics and encodings for generated appearances (L2). //! -//! See the README: the metrics are approximations by character class (no vendor metrics files -//! are bundled). The full font subsystem lands in M2.2/M7. +//! Standard-14 advance widths are exact (`Std14`, ISO 32000-2 Annex D). What remains approximate +//! is everything that needs a font *program*: this crate reads font dictionaries, never embedded +//! programs, so it has no glyph outlines for document fonts and cannot subset or re-embed. That +//! is phase F 4.2 in docs/plan/execution-plan.md. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -12,6 +14,7 @@ pub mod pdf; mod script; mod shaping; pub mod source_shaping; +mod std14; pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, @@ -21,27 +24,14 @@ pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, }; pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has}; +pub use std14::Std14; -/// Approximate advance of `s` in Helvetica (or Arial) at `size` points. +/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics. +/// +/// Kept as a free function because generated appearances overwhelmingly use Helvetica; reach for +/// [`Std14`] directly for any other face, and never for a scaled approximation of this one. pub fn helvetica_width(s: &str, size: f64) -> f64 { - let units: f64 = s - .chars() - .map(|c| match c { - ' ' | 'i' | 'j' | 'l' | '\'' | '!' | '|' | '.' | ',' | ':' | ';' | 'I' => 260.0, - 'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '/' | '-' | '"' => 333.0, - 'm' => 833.0, - 'w' => 722.0, - 'M' => 833.0, - 'W' => 944.0, - 'J' | 'c' | 'k' | 's' | 'v' | 'x' | 'y' | 'z' => 500.0, - '0'..='9' | 'a'..='z' | '$' | '#' | '?' | '_' => 556.0, - 'A'..='Z' => 680.0, - '@' => 1015.0, - _ if c.is_whitespace() => 260.0, - _ => 584.0, - }) - .sum(); - units * size / 1000.0 + Std14::Helvetica.text_width(s, size) } /// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index e3de687ff5..cd53ab0b8d 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,9 +1,15 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); the standard 14 fonts without widths use -//! approximations. Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and -//! `/Differences` glyph names, ISO 32000-2 Annex D). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact +//! published metrics ([`crate::Std14`]). +//! +//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph +//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own +//! built-in encoding governs and we hold no table for it — Symbol, ZapfDingbats, MacExpert — this +//! module reports nothing rather than the Latin letter at that code. Callers must treat a missing +//! meaning as "unknown", never as "absent": `redact` matches on this map, and a wrong character +//! there is worse than no character. use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; @@ -19,14 +25,6 @@ enum Codes { Ranges(Vec<(usize, Vec, Vec)>), } -#[derive(Clone, Debug, PartialEq)] -enum Std14 { - Helvetica, - Times, - Courier, - Symbolic, -} - #[derive(Clone, Debug, PartialEq)] pub struct Metrics { codes: Codes, @@ -39,7 +37,8 @@ pub struct Metrics { /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). cid_map: Vec<(u32, u32, u32)>, default: f64, - std14: Option, + /// The standard-14 face to measure with when the dictionary carries no `/Widths`. + std14: Option, /// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3). pub scale: f64, /// Glyph box in text space per unit of font size. @@ -526,7 +525,7 @@ impl Metrics { cid_ranges: Vec::new(), cid_map: Vec::new(), default: 500.0, - std14: Some(Std14::Helvetica), + std14: Some(crate::Std14::Helvetica), scale: 0.001, ascent: 0.9, descent: -0.25, @@ -655,16 +654,10 @@ impl Metrics { } } } else if m.widths.is_empty() { - let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase(); - m.std14 = Some(if base.contains("courier") { - Std14::Courier - } else if base.contains("times") { - Std14::Times - } else if base.contains("symbol") || base.contains("dingbats") { - Std14::Symbolic - } else { - Std14::Helvetica - }); + let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned(); + // The real face, bold and italic included: the published metrics differ by style, + // so Times-Bold is not Times-Roman and neither is Helvetica scaled. + m.std14 = crate::Std14::from_base_font(&base).or(Some(crate::Std14::Helvetica)); } } if let Some(d) = descriptor { @@ -726,14 +719,13 @@ impl Metrics { if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { return w * self.scale; } - match &self.std14 { - Some(Std14::Courier) => 0.6, - Some(Std14::Symbolic) => 0.75, - Some(f) => { - let c = char::from_u32(code).filter(|c| !c.is_control()).unwrap_or('n'); - let w = crate::helvetica_width(&c.to_string(), 1.0); - if *f == Std14::Times { w * 0.92 } else { w } - } + match self.std14 { + // A font with no /Widths is one of the standard 14; use its published metrics. + // Codes the font's encoding leaves undefined fall back to /MissingWidth. + Some(f) => match u8::try_from(code).map(|c| f.width(c)).unwrap_or(0.0) { + 0.0 => self.default * self.scale, + w => w / 1000.0, + }, None => self.default * self.scale, } } @@ -1006,13 +998,49 @@ fn parse_to_unicode_within(data: &[u8], out: &mut Unicode, mut budget: usize) { out.explicit_low = bit_set_below_64k(out.explicit.keys().copied()); } -/// A glyph name's Unicode (Annex D names, `uniXXXX`, `uXXXX[XX]`, single letters). -pub fn glyph_unicode(name: &str) -> Option { - if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(name)) { - return char::from_u32(crate::encodings::NAMES[i].1); +/// A glyph name's text, by the Adobe Glyph List *algorithm* — the published naming rules, not +/// the AGL data table, which this crate does not hold. +/// +/// A name is cut at its first `.`, because a suffix marks a variant of the same character rather +/// than a different one (`a.sc` is small-capital `a`, `one.oldstyle` is still `1`). What remains +/// is split on `_` into components, so a ligature name yields several characters (`f_i` is "fi"). +/// Each component is read as an Annex D name, as `uniXXXX` with one or more four-hex-digit values, +/// or as `uXXXX` through `uXXXXXX`. +/// +/// Returns `None` rather than a partial answer: a name is either fully understood or not, and half +/// a ligature would be worse than admitting we cannot read it. +pub fn glyph_text(name: &str) -> Option { + let stem = name.split('.').next().filter(|s| !s.is_empty())?; + let mut out = String::new(); + for part in stem.split('_') { + out.push_str(&glyph_component(part)?); } - let hex = name.strip_prefix("uni").filter(|h| h.len() == 4).or_else(|| name.strip_prefix('u').filter(|h| (4..=6).contains(&h.len())))?; - u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + Some(out).filter(|s| !s.is_empty()) +} + +/// One `_`-separated component of a glyph name. +fn glyph_component(part: &str) -> Option { + if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) { + return char::from_u32(crate::encodings::NAMES[i].1).map(String::from); + } + // `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked. + let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit()); + if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) { + let mut s = String::new(); + for i in (0..hex.len()).step_by(4) { + // A lone surrogate half is not a character; such a name is not usable text. + s.push(char::from_u32(u32::from_str_radix(hex.get(i..i.saturating_add(4))?, 16).ok()?)?); + } + return Some(s); + } + let hex = part.strip_prefix('u').filter(|h| (4..=6).contains(&h.len()) && hexish(h))?; + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).map(String::from) +} + +/// [`glyph_text`] for names that stand for exactly one character. +pub fn glyph_unicode(name: &str) -> Option { + let mut chars = glyph_text(name)?.chars().collect::>().into_iter(); + chars.next().filter(|_| chars.len() == 0) } /// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding. @@ -1025,12 +1053,33 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { Some(Object::Dict(d)) => d.name(b"BaseEncoding").map(<[u8]>::to_vec), _ => None, }; - let table: &[u32; 256] = match base_name.as_deref() { - Some(b"WinAnsiEncoding") => &crate::encodings::WIN_ANSI, - Some(b"MacRomanEncoding") => &crate::encodings::MAC_ROMAN, - _ => &crate::encodings::STANDARD, + // Symbol and ZapfDingbats carry their own built-in encodings, which are not Latin + // (ISO 32000-2 9.6.6.1): Symbol code 0x61 is `alpha`, not `a`. We do not hold those two + // tables, so with no base encoding named they get no implicit mapping at all. Reporting + // nothing is honest; reporting Latin letters silently corrupts text extraction, find, + // copy and — because `redact` matches on this map — redaction. + // + // Deliberately keyed on the *name*, not on the `/FontDescriptor` symbolic flag. A great + // many ordinary Latin TrueType subsets set that flag and still rely on a Latin encoding, + // so suppressing on the flag would lose real text on a large share of real documents. + // An explicitly named base encoding is a deliberate statement and is always honoured, + // even on Symbol. + let symbolic_builtin = matches!( + font.name(b"BaseFont").map(|b| crate::Std14::from_base_font(&String::from_utf8_lossy(b))), + Some(Some(crate::Std14::Symbol | crate::Std14::ZapfDingbats)) + ); + let table: Option<&[u32; 256]> = match base_name.as_deref() { + Some(b"WinAnsiEncoding") => Some(&crate::encodings::WIN_ANSI), + Some(b"MacRomanEncoding") => Some(&crate::encodings::MAC_ROMAN), + Some(b"StandardEncoding") => Some(&crate::encodings::STANDARD), + // MacExpert is small caps, oldstyle figures and fractions, not Latin at these codes, + // and we hold no table for it either. + Some(b"MacExpertEncoding") => None, + _ if symbolic_builtin => None, + // Standard is the default for a simple font that names no base encoding. + _ => Some(&crate::encodings::STANDARD), }; - for (code, u) in table.iter().enumerate() { + for (code, u) in table.into_iter().flatten().enumerate() { if let Some(c) = char::from_u32(*u).filter(|_| *u != 0) { out.encoding.insert(code as u32, c.to_string()); } @@ -1044,8 +1093,10 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // Out-of-range codes map nothing a simple font can show. Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX), Object::Name(n) => { - match glyph_unicode(&String::from_utf8_lossy(n)) { - Some(c) => out.encoding.insert(code, c.to_string()), + // A name we cannot read removes any inherited meaning for that code, + // rather than leaving the base encoding's letter standing in for it. + match glyph_text(&String::from_utf8_lossy(n)) { + Some(t) => out.encoding.insert(code, t), None => out.encoding.remove(&code), }; code = code.saturating_add(1); @@ -1594,3 +1645,137 @@ mod tests { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } } + +#[cfg(test)] +mod encoding_tests { + use pdfcraft_cos::{Dict, Document, Object}; + + use super::*; + + /// A simple Type 1 font, optionally symbolic-flagged and optionally with an `/Encoding`. + fn simple(base: &str, flags: Option, enc: Option) -> (Document, Dict) { + let doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + d.set(b"Subtype".to_vec(), Object::name("Type1")); + d.set(b"BaseFont".to_vec(), Object::name(base)); + if let Some(f) = flags { + let mut desc = Dict::new(); + desc.set(b"Flags".to_vec(), Object::Int(f)); + d.set(b"FontDescriptor".to_vec(), Object::Dict(desc)); + } + if let Some(e) = enc { + d.set(b"Encoding".to_vec(), e); + } + (doc, d) + } + + fn differences(items: Vec) -> Object { + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(items)); + Object::Dict(enc) + } + + #[test] + fn glyph_names_follow_the_naming_algorithm() { + // Annex D names and the two hex forms, as before. + assert_eq!(glyph_text("quotedblleft").as_deref(), Some("\u{201c}")); + assert_eq!(glyph_text("uni20AC").as_deref(), Some("\u{20ac}")); + assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}")); + // A suffix marks a variant of the same character, so the stem still reads. + assert_eq!(glyph_text("a.sc").as_deref(), Some("a")); + assert_eq!(glyph_text("one.oldstyle").as_deref(), Some("1")); + assert_eq!(glyph_text("A.alt017").as_deref(), Some("A")); + // `_` joins components, so a ligature name is more than one character. + assert_eq!(glyph_text("f_i").as_deref(), Some("fi")); + assert_eq!(glyph_text("f_f_l").as_deref(), Some("ffl")); + assert_eq!(glyph_text("a_uni0301").as_deref(), Some("a\u{301}")); + // `uniXXXX` carries several values, so one name can be a whole cluster. + assert_eq!(glyph_text("uni00660069").as_deref(), Some("fi")); + assert_eq!(glyph_text("uni004100300041").as_deref(), Some("A0A")); + // Both forms combine with a suffix. + assert_eq!(glyph_text("uni00660069.alt").as_deref(), Some("fi")); + + // Not names: unknown, empty, a half-read ligature, bad hex, and the `+` that + // `from_str_radix` would otherwise accept. + assert_eq!(glyph_text("nonsense"), None); + assert_eq!(glyph_text(""), None); + assert_eq!(glyph_text(".notdef"), None); + assert_eq!(glyph_text("f_nonsense"), None, "half a ligature is not an answer"); + assert_eq!(glyph_text("uni00"), None, "not a whole four-digit value"); + assert_eq!(glyph_text("uni0041004"), None); + assert_eq!(glyph_text("uni+041"), None, "a sign is not a hex digit"); + assert_eq!(glyph_text("u+0041"), None); + assert_eq!(glyph_text("uniD800"), None, "a lone surrogate half is not a character"); + // Glyph-index names carry no meaning, and must not be guessed at. + assert_eq!(glyph_text("g3"), None); + assert_eq!(glyph_text("index42"), None); + + // `glyph_unicode` is the single-character view of the same algorithm. + assert_eq!(glyph_unicode("uni20AC"), Some('\u{20ac}')); + assert_eq!(glyph_unicode("a.sc"), Some('a')); + assert_eq!(glyph_unicode("f_i"), None, "a ligature is not one character"); + } + + #[test] + fn a_ligature_or_unreadable_name_in_differences_is_handled() { + // A ligature name maps one code to two characters, so text extraction reads "fi". + let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("f_i")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"A"), "fi"); + + // An unreadable name must *remove* the base encoding's letter rather than leave it + // standing in: code 65 is no longer "A", because the font says it is something else. + let (doc, d) = simple("Custom", None, Some(differences(vec![Object::Int(65), Object::name("g7")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(65), None); + assert_eq!(m.text_of(66).as_deref(), Some("B"), "neighbouring codes keep their meaning"); + } + + #[test] + fn symbol_and_dingbats_are_not_read_as_latin() { + // Symbol's built-in encoding puts `alpha` at 0x61. We hold no table for it, so the honest + // answer is none at all -- never "a", which would corrupt find, copy and redaction. + for base in ["Symbol", "ZapfDingbats", "ABCDEF+Symbol"] { + let (doc, d) = simple(base, Some(4), None); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), None, "{base} must not claim 'a'"); + assert_eq!(m.decode(b"abg"), "", "{base}"); + } + + // MacExpert is small caps and oldstyle figures at these codes, and we hold no table. + let (doc, d) = simple("Times-Roman", None, Some(Object::name("MacExpertEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61), None); + + // /Differences still applies on top of a built-in encoding we cannot read. + let (doc, d) = simple("Symbol", Some(4), Some(differences(vec![Object::Int(0x61), Object::name("uni03B1")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61).as_deref(), Some("\u{3b1}"), "the font told us this one"); + assert_eq!(m.text_of(0x62), None, "and nothing about this one"); + } + + #[test] + fn a_named_base_encoding_is_always_honoured() { + // Naming an encoding is a deliberate statement, so it wins even on Symbol. + for name in ["WinAnsiEncoding", "MacRomanEncoding", "StandardEncoding"] { + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name(name)); + let (doc, d) = simple("Symbol", Some(4), Some(Object::Dict(enc))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61).as_deref(), Some("a"), "{name} was asked for by name"); + } + } + + /// Pins a deliberate restraint, so it is not "fixed" into a regression: a great many ordinary + /// Latin TrueType subsets set the `/FontDescriptor` symbolic flag and still rely on a Latin + /// encoding. Suppressing the implicit table on the flag would lose real text on a large share + /// of real documents, so only Symbol and ZapfDingbats -- named, and unambiguous -- suppress it. + #[test] + fn the_symbolic_flag_alone_does_not_suppress_the_latin_default() { + let (doc, d) = simple("SomeSubsetFont", Some(4), None); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.text_of(0x61).as_deref(), Some("a")); + assert_eq!(m.decode(b"abc"), "abc"); + } +} diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs new file mode 100644 index 0000000000..0aca38a120 --- /dev/null +++ b/crates/fonts/src/std14.rs @@ -0,0 +1,526 @@ +//! Standard-14 font metrics (ISO 32000-2 Annex D; the Adobe Core14 AFM metrics), as advance +//! widths in glyph space — thousandths of an em. +//! +//! A PDF may name any of the fourteen standard fonts without embedding a program, and the viewer +//! has to supply the metrics. These are those metrics, exactly, replacing the character-class +//! estimate that generated appearances used before: that estimate had Times at 0.9 x Helvetica, +//! which makes a capital A 600 where the specification says 722. +//! +//! **Generated.** The tables are data, not typefaces, and are the item AGENTS.md §1.1 permits as +//! Adobe-authored non-visual technical data; `ATTRIBUTION.toml` carries the entry with +//! `adobe_data = true`. Each row is indexed by character **code**, not by Unicode: the twelve text +//! fonts are indexed by WinAnsiEncoding, which is what PdfCraft writes into the appearance streams +//! it generates, and Symbol and ZapfDingbats by their own built-in encodings. A zero means the +//! encoding leaves that code undefined. + +/// One of the fourteen standard fonts (ISO 32000-2 §9.6.2.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Std14 { + Helvetica, + HelveticaBold, + HelveticaOblique, + HelveticaBoldOblique, + TimesRoman, + TimesBold, + TimesItalic, + TimesBoldItalic, + Courier, + CourierBold, + CourierOblique, + CourierBoldOblique, + Symbol, + ZapfDingbats, +} + +impl Std14 { + /// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers + /// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`). + /// A subset prefix such as `ABCDEF+Helvetica` is ignored. + pub fn from_base_font(base: &str) -> Option { + let base = base.rsplit('+').next().unwrap_or(base); + let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect(); + let has = |w: &str| flat.contains(w); + let (bold, italic) = (has("bold"), has("italic") || has("oblique")); + if has("symbol") { + return Some(Self::Symbol); + } + if has("zapf") || has("dingbat") { + return Some(Self::ZapfDingbats); + } + if has("courier") || has("mono") { + return Some(match (bold, italic) { + (false, false) => Self::Courier, + (true, false) => Self::CourierBold, + (false, true) => Self::CourierOblique, + (true, true) => Self::CourierBoldOblique, + }); + } + if has("times") || has("serif") || has("roman") { + return Some(match (bold, italic) { + (false, false) => Self::TimesRoman, + (true, false) => Self::TimesBold, + (false, true) => Self::TimesItalic, + (true, true) => Self::TimesBoldItalic, + }); + } + if has("helvetica") || has("arial") { + return Some(match (bold, italic) { + (false, false) => Self::Helvetica, + (true, false) => Self::HelveticaBold, + (false, true) => Self::HelveticaOblique, + (true, true) => Self::HelveticaBoldOblique, + }); + } + None + } + + /// The advance of one character `code`, in thousandths of an em. Zero where the font's + /// encoding leaves the code undefined. + pub fn width(self, code: u8) -> f64 { + f64::from(WIDTHS[self as usize][code as usize]) + } + + /// The advance of `code` at `size` points. + pub fn advance(self, code: u8, size: f64) -> f64 { + self.width(code) * size / 1000.0 + } + + /// The advance of `text` at `size` points, encoded as the twelve text fonts are written: + /// WinAnsiEncoding, with characters it cannot represent replaced as `win_ansi` replaces them. + /// + /// Symbol and ZapfDingbats have their own built-in encodings and are not WinAnsi, so this + /// measures them as if they were; pass codes to [`Std14::width`] for those. + pub fn text_width(self, text: &str, size: f64) -> f64 { + crate::win_ansi(text).iter().map(|&c| self.width(c)).sum::() * size / 1000.0 + } + + /// Courier is the only fixed-pitch family of the fourteen. + pub fn is_fixed_pitch(self) -> bool { + matches!(self, Self::Courier | Self::CourierBold | Self::CourierOblique | Self::CourierBoldOblique) + } + + /// The `/BaseFont` name for this font, as it is written into a PDF. + pub fn base_font(self) -> &'static str { + BASE_FONTS[self as usize] + } +} + +static BASE_FONTS: [&str; 14] = [ + "Helvetica", + "Helvetica-Bold", + "Helvetica-Oblique", + "Helvetica-BoldOblique", + "Times-Roman", + "Times-Bold", + "Times-Italic", + "Times-BoldItalic", + "Courier", + "Courier-Bold", + "Courier-Oblique", + "Courier-BoldOblique", + "Symbol", + "ZapfDingbats", +]; + +/// Advance widths by code, one row per [`Std14`] variant in declaration order. +#[rustfmt::skip] +static WIDTHS: [[u16; 256]; 14] = [ + // Helvetica + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // HelveticaOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // TimesRoman + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0, + 500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0, + 0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333, + 400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444, + 722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500, + 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500, + ], + // TimesBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333, + 400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500, + ], + // TimesItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0, + 500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0, + 0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556, + 250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333, + 400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500, + 611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500, + 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444, + ], + // TimesBoldItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611, + 250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333, + 400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500, + 667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444, + ], + // Courier + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // Symbol + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, + 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, + 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, + 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, + 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, + 400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, + 823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, + 768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, + 494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, + 0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, + ], + // ZapfDingbats + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933, + 911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537, + 577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831, + 923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826, + 815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762, + 762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0, + 390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834, + 873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874, + 0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0, + ], +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// Widths from the published Core14 metrics, typed in here by hand so the table is checked + /// against the specification rather than against whatever produced it. + #[test] + fn widths_match_the_published_metrics() { + let cases: &[(Std14, char, u16)] = &[ + // Helvetica + (Std14::Helvetica, ' ', 278), + (Std14::Helvetica, 'A', 667), + (Std14::Helvetica, 'W', 944), + (Std14::Helvetica, 'i', 222), + (Std14::Helvetica, 'm', 833), + (Std14::Helvetica, '0', 556), + (Std14::Helvetica, '@', 1015), + (Std14::HelveticaBold, 'A', 722), + (Std14::HelveticaBold, 'i', 278), + // Times: not Helvetica scaled by 0.9, which is what the old estimate assumed. + (Std14::TimesRoman, ' ', 250), + (Std14::TimesRoman, 'A', 722), + (Std14::TimesRoman, 'W', 944), + (Std14::TimesRoman, 'i', 278), + (Std14::TimesRoman, 'm', 778), + (Std14::TimesBold, 'A', 722), + (Std14::TimesItalic, 'A', 611), + // Courier is fixed pitch at 600 for every glyph. + (Std14::Courier, ' ', 600), + (Std14::Courier, 'A', 600), + (Std14::Courier, 'm', 600), + (Std14::CourierBoldOblique, 'W', 600), + ]; + for &(font, ch, want) in cases { + let code = crate::win_ansi(&ch.to_string()); + assert_eq!(code.len(), 1, "{ch:?} is one WinAnsi byte"); + assert_eq!(font.width(code[0]) as u16, want, "{font:?} {ch:?}"); + } + } + + #[test] + fn courier_is_fixed_pitch_everywhere_it_is_defined() { + for font in [Std14::Courier, Std14::CourierBold, Std14::CourierOblique, Std14::CourierBoldOblique] { + assert!(font.is_fixed_pitch()); + for code in 0..=255u8 { + let w = font.width(code); + assert!(w == 0.0 || w == 600.0, "{font:?} code {code} has width {w}"); + } + } + assert!(!Std14::Helvetica.is_fixed_pitch()); + } + + #[test] + fn every_text_font_covers_printable_ascii_and_latin1() { + for font in [Std14::Helvetica, Std14::TimesRoman, Std14::Courier, Std14::TimesBoldItalic] { + for code in 0x20..=0x7Eu8 { + assert!(font.width(code) > 0.0, "{font:?} has no width for ASCII {code}"); + } + for code in 0xA0..=0xFFu8 { + assert!(font.width(code) > 0.0, "{font:?} has no width for Latin-1 {code}"); + } + } + } + + #[test] + fn symbol_and_dingbats_have_their_own_encodings() { + // Symbol's code 0x61 is alpha, not 'a'; ZapfDingbats' is a scissors glyph. + assert!(Std14::Symbol.width(0x61) > 0.0); + assert!(Std14::ZapfDingbats.width(0x61) > 0.0); + // Neither defines the Latin-1 upper half the text fonts do. + assert_eq!(Std14::Symbol.width(0xFF), 0.0); + } + + #[test] + fn base_font_names_round_trip() { + for i in 0..14usize { + let font = [ + Std14::Helvetica, + Std14::HelveticaBold, + Std14::HelveticaOblique, + Std14::HelveticaBoldOblique, + Std14::TimesRoman, + Std14::TimesBold, + Std14::TimesItalic, + Std14::TimesBoldItalic, + Std14::Courier, + Std14::CourierBold, + Std14::CourierOblique, + Std14::CourierBoldOblique, + Std14::Symbol, + Std14::ZapfDingbats, + ][i]; + assert_eq!(Std14::from_base_font(font.base_font()), Some(font), "{}", font.base_font()); + } + } + + #[test] + fn base_font_aliases_and_subset_prefixes_resolve() { + assert_eq!(Std14::from_base_font("ABCDEF+Helvetica-Bold"), Some(Std14::HelveticaBold)); + assert_eq!(Std14::from_base_font("Arial"), Some(Std14::Helvetica)); + assert_eq!(Std14::from_base_font("Arial,BoldItalic"), Some(Std14::HelveticaBoldOblique)); + assert_eq!(Std14::from_base_font("TimesNewRoman,Bold"), Some(Std14::TimesBold)); + assert_eq!(Std14::from_base_font("CourierNew-Oblique"), Some(Std14::CourierOblique)); + assert_eq!(Std14::from_base_font("ZapfDingbats"), Some(Std14::ZapfDingbats)); + assert_eq!(Std14::from_base_font("Wingdings"), None); + assert_eq!(Std14::from_base_font(""), None); + } + + #[test] + fn text_width_sums_the_glyphs_and_scales_with_size() { + let h = Std14::Helvetica; + // "AW" = 667 + 944 = 1611 thousandths of an em. + assert!((h.text_width("AW", 1000.0) - 1611.0).abs() < 1e-9); + assert!((h.text_width("AW", 12.0) - 1611.0 * 12.0 / 1000.0).abs() < 1e-9); + assert_eq!(h.text_width("", 12.0), 0.0); + // Courier: every character is 600, so the width is purely a count. + assert!((Std14::Courier.text_width("hello", 10.0) - 5.0 * 6.0).abs() < 1e-9); + } + + #[test] + fn hostile_text_does_not_panic_and_stays_finite() { + for font in [Std14::Helvetica, Std14::Symbol, Std14::Courier] { + for s in ["", "\0", "\u{202E}", "💥", &"a".repeat(10_000), "\u{FFFD}"] { + let w = font.text_width(s, 12.0); + assert!(w.is_finite() && w >= 0.0, "{font:?} {s:?} -> {w}"); + } + assert!(font.advance(0, 12.0).is_finite()); + assert!(font.advance(255, 0.0).is_finite()); + } + } +} diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index d7a57482c6..685a818a9c 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -16,8 +16,7 @@ pub struct Word { pub rect: [f64; 4], } -/// Shapes drawn on the page, in user space: rectangles (`re`) and straight horizontal -/// segments. +/// Shapes drawn on the page, in user space: axis-aligned rectangle paths and horizontal segments. #[derive(Clone, Debug, Default, PartialEq)] pub struct Shapes { pub boxes: Vec<[f64; 4]>, @@ -40,6 +39,60 @@ pub struct Candidate { } /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. +#[derive(Default)] +struct DetectionPath { + points: Vec<[f64; 2]>, + rules: Vec<[f64; 4]>, + complex: bool, + closed: bool, +} + +impl DetectionPath { + fn finish(&mut self, paths: &mut Vec) { + let path = std::mem::take(self); + if !path.rules.is_empty() || (!path.complex && path.points.len() >= 4) { + paths.push(path); + } + } + + fn rectangle(&self, implicitly_closed: bool) -> Option<[f64; 4]> { + if self.complex { + return None; + } + if self.points.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; + let mut points = self.points.as_slice(); + let repeated_end = points.len() > 1 && near(points[0], *points.last()?); + if repeated_end { + points = &points[..points.len() - 1]; + } + if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + return None; + } + let mut horizontal = [false; 4]; + for i in 0..4 { + let (a, b) = (points[i], points[(i + 1) % 4]); + let h = (a[1] - b[1]).abs() < 1e-6; + let v = (a[0] - b[0]).abs() < 1e-6; + if h == v { + return None; // Diagonal or zero-length edge. + } + horizontal[i] = h; + } + if (0..4).any(|i| horizontal[i] == horizontal[(i + 1) % 4]) { + return None; + } + Some([ + points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min), + points.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min), + points.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max), + points.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max), + ]) + } +} + pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let mut out = Shapes::default(); let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out }; @@ -61,7 +114,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let mut ctm = Matrix::IDENTITY; let mut stack = Vec::new(); let mut path_boxes: Vec<[f64; 4]> = Vec::new(); - let mut path_rules: Vec<[f64; 4]> = Vec::new(); + let mut subpaths: Vec = Vec::new(); + let mut path = DetectionPath::default(); let mut cur: Option<[f64; 2]> = None; for op in parse(&data).ops { match op.op.as_slice() { @@ -73,27 +127,75 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { } } b"re" => { + path.finish(&mut subpaths); if let Some([x, y, w, h]) = op.nums::<4>() { path_boxes.push(ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])); + let (x, y) = ctm.apply(x, y); + cur = Some([x, y]); + path.complex = true; + path.points.push([x, y]); } } b"m" => { + path.finish(&mut subpaths); cur = op.nums::<2>().map(|[x, y]| { let (a, b) = ctm.apply(x, y); [a, b] - }) + }); + if let Some(p) = cur { + path.points.push(p); + } } b"l" => { if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) { + // Continuing after closepath is not a single four-edge rectangle. + if path.closed { + path.complex = true; + } let (bx, by) = ctm.apply(x, y); let b = [bx, by]; + if !path.complex { + if path.points.len() >= 5 { + path.complex = true; + } else { + if path.points.is_empty() { + path.points.push(a); + } + path.points.push(b); + } + } if (a[1] - b[1]).abs() < 0.5 && (a[0] - b[0]).abs() > 1.0 { - path_rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]); + path.rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]); } cur = Some(b); } } + b"c" | b"v" | b"y" => { + path.complex = true; + let end = if op.op.as_slice() == b"c" { op.nums::<6>().map(|v| [v[4], v[5]]) } else { op.nums::<4>().map(|v| [v[2], v[3]]) }; + if let Some([x, y]) = end { + let (x, y) = ctm.apply(x, y); + cur = Some([x, y]); + } + } + b"h" => { + path.closed = true; + cur = path.points.first().copied().or(cur); + } b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if matches!(op.op.as_slice(), b"s" | b"b" | b"b*") { + path.closed = true; + } + path.finish(&mut subpaths); + let filled = !matches!(op.op.as_slice(), b"S" | b"s"); + let mut path_rules = Vec::new(); + for subpath in subpaths.drain(..) { + if let Some(rect) = subpath.rectangle(filled) { + path_boxes.push(rect); + } else { + path_rules.extend(subpath.rules); + } + } for r in path_boxes.drain(..) { // A very flat filled rectangle is a rule. if r[3] - r[1] < 1.5 && r[2] - r[0] > 1.0 { @@ -107,7 +209,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { } b"n" => { path_boxes.clear(); - path_rules.clear(); + subpaths.clear(); + path = DetectionPath::default(); cur = None; } _ => {} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 1e913a5b5c..0256bfafb9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1865,3 +1865,81 @@ fn invalid_appearance_patches_are_refused_before_anything_is_written() { assert!(set_props(&mut doc, "name", &props).is_err()); assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } + +#[test] +fn page_shapes_preserves_closed_path_rectangle_height() { + let shapes = |content: &[u8]| { + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); + crate::detect::page_shapes(&doc, 0) + }; + let shorthand = shapes(b"50 100 120 40 re S"); + let outlined = shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h S"); + assert_eq!(outlined, shorthand, "equivalent rectangle encodings must have equal detection geometry"); + let fields = crate::detect::detect(&[], &outlined, &[], &[]); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].rect, [51.0, 101.0, 169.0, 139.0]); +} + +fn detection_test_shapes(content: &[u8]) -> crate::detect::Shapes { + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap(); + crate::detect::page_shapes(&doc, 0) +} + +#[test] +fn page_shapes_accepts_rectangle_closure_and_paint_variants() { + for end in ["h S", "s", "50 100 l S", "f", "F", "f*", "B", "B*", "b", "b*"] { + let content = format!("50 100 m 170 100 l 170 140 l 50 140 l {end}"); + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0]], "{end}"); + assert!(shapes.rules.is_empty(), "rectangle edges must not become extra fields: {end}"); + } +} + +#[test] +fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { + for content in [ + "50 100 m 170 100 l 170 140 l 50 140 l S", + "50 100 m 170 110 l 170 150 l 50 140 l h S", + "50 100 m 170 100 l 170 140 l 100 150 80 150 50 140 c h S", + "50 100 m 170 100 l 170 140 l h 50 140 l f", + "50 100 m 170 100 l 50 100 l 50 140 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + +#[test] +fn page_shapes_preserves_multiple_subpaths_and_discards_unpainted_paths() { + let shapes = detection_test_shapes(b"50 100 120 40 re 200 100 m 320 100 l 320 140 l 200 140 l h 50 400 m 170 400 l S"); + assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); + assert_eq!(shapes.rules, [[50.0, 400.0, 170.0, 400.0]]); + assert_eq!(detection_test_shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h W n"), crate::detect::Shapes::default()); + let continued = detection_test_shapes(b"50 100 120 40 re 170 100 l 170 140 l 50 140 l h S"); + assert_eq!(continued.boxes, [[50.0, 100.0, 170.0, 140.0]], "continuing re must not invent a second box"); +} + +#[test] +fn page_shapes_transforms_path_rectangles_and_preserves_rules() { + let scaled = detection_test_shapes(b"q 2 0 0 2 0 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S Q 10 20 m 100 20 l S"); + assert_eq!(scaled.boxes, [[100.0, 200.0, 340.0, 280.0]]); + assert_eq!(scaled.rules, [[10.0, 20.0, 100.0, 20.0]]); + let rotated = detection_test_shapes(b"0 1 -1 0 300 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S"); + assert_eq!(rotated.boxes, [[160.0, 50.0, 200.0, 170.0]]); +} + +#[test] +fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { + let paths = "50 100 m 170 100 l 170 140 l 50 140 l 200 100 m 320 100 l 320 140 l 200 140 l"; + let stroked = detection_test_shapes(format!("{paths} s").as_bytes()); + assert_eq!(stroked.boxes, [[200.0, 100.0, 320.0, 140.0]]); + assert_eq!(stroked.rules, [[50.0, 100.0, 170.0, 100.0], [50.0, 140.0, 170.0, 140.0]]); + let filled = detection_test_shapes(format!("{paths} f").as_bytes()); + assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); + assert!(filled.rules.is_empty()); +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 1d3f5c29a2..c8ee812180 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1037,6 +1037,26 @@ mod session { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } + #[test] + fn a_scripts_time_starts_when_its_context_is_ready() { + // The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is + // not abandoned, its time starting only when the engine says it is ready. + use crate::xfa_session::START_DELAY_MS; + use std::sync::atomic::Ordering::Relaxed; + let mut s = XfaSession::new(XfaDoc::default()); + START_DELAY_MS.store(300, Relaxed); + let mut quick = script("1", false, "form1[0].page1[0].go[0]", "click"); + quick.timeout = std::time::Duration::from_millis(50); + let js = s.run(quick); + let mut quick = script("1", true, "form1[0].page1[0].go[0]", "click"); + quick.timeout = std::time::Duration::from_millis(50); + let fc = s.run(quick); + START_DELAY_MS.store(0, Relaxed); + assert!(!js.abandoned && js.result.as_deref() == Some("1"), "{js:?}"); + assert!(!fc.abandoned && fc.result.as_deref() == Some("1"), "{fc:?}"); + assert_eq!(s.dead(), None); + } + #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index ac200eb87e..a2361da7e4 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1458,7 +1458,6 @@ pub(crate) fn warm_up() { pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); let mut ctx = Context::default(); - started(); ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); @@ -1467,6 +1466,8 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo let mut out = XfaOutcome::default(); let result = (|| -> JsResult<()> { install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; // Direct eval inside a function called on the node: `this` is the node and the // completion value is the script's last expression (what a calculate script yields). diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index e85fc1afa8..c2c5ee3cc3 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -13,6 +13,10 @@ use crate::Limits; use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome}; +/// Tests: milliseconds the engine is made to take before it is ready for a script. +#[cfg(test)] +pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// What one script run needs. #[derive(Clone, Debug)] pub struct Script { @@ -25,15 +29,29 @@ pub struct Script { pub timeout: std::time::Duration, } -/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the form. -fn run_script(s: Script, doc: &XfaDoc) -> XfaOutcome { +/// Run `s` here: a fresh context (JavaScript) or interpreter (FormCalc) on its copy of the +/// form. `started` is called once the engine is ready for the script (its context built), so +/// a time limit on the script runs from then, not from when it was asked for. +fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { + // Tests make the engine slow to be ready, to see that the time isn't the script's. + #[cfg(test)] + let started = &|| { + let ms = START_DELAY_MS.load(std::sync::atomic::Ordering::Relaxed); + if ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(ms)); + } + started(); + }; if s.formcalc { + // FormCalc has no engine to start: its time (and its own deadline) begins at once. + started(); return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout)); } if let Some(why) = crate::refuse(&s.source) { + started(); return XfaOutcome { error: Some(why), ..Default::default() }; } - crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, &|| {}) + crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started) } #[cfg(not(target_arch = "wasm32"))] @@ -45,7 +63,10 @@ mod native { use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome}; enum Reply { + /// The thread is up. Ready, + /// The engine is ready for the script just sent: its time starts now. + Started, Outcome(Box), } @@ -85,7 +106,10 @@ mod native { // The caller is gone when a send fails: nothing to report then. let _ = reply.send(Reply::Ready); while let Ok(s) = inbox.recv() { - let o = run_script(*s, &doc); + let started = reply.clone(); + let o = run_script(*s, &doc, &move || { + let _ = started.send(Reply::Started); + }); let _ = reply.send(Reply::Outcome(Box::new(o))); } }); @@ -103,8 +127,9 @@ mod native { self.worker.as_mut().ok_or_else(|| "the script engine is not running".into()) } - /// Run one script, waiting at most its `timeout`; past that it is abandoned on its - /// thread and the session is dead. + /// Run one script, waiting at most its `timeout` from when the engine is ready for it + /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is + /// abandoned on its thread and the session is dead. pub fn run(&mut self, script: Script) -> XfaOutcome { let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; @@ -121,15 +146,28 @@ mod native { self.worker = None; return failed("the script engine stopped with an internal error".into()); } - let deadline = Instant::now().checked_add(timeout); + // The engine's start, then the script's own time. + let (mut running, mut limit) = (false, ENGINE_START_LIMIT); + let mut deadline = Instant::now().checked_add(limit); loop { - let left = deadline.map_or(timeout, |d| d.saturating_duration_since(Instant::now())); + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); match worker.from.recv_timeout(left) { Ok(Reply::Outcome(o)) => return *o, + Ok(Reply::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } // Not expected here: wait on for the outcome. Ok(Reply::Ready) => continue, Err(RecvTimeoutError::Timeout) => { - let why = format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()); + let why = if running { + format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()) + } else { + format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()) + }; self.dead = Some(why.clone()); // Dropping the sender ends the thread's loop once the script is done. self.worker = None; @@ -165,7 +203,7 @@ mod native { } pub fn run(&mut self, script: Script) -> XfaOutcome { - run_script(script, &self.doc) + run_script(script, &self.doc, &|| {}) } } } diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 8e17493449..c420d2d59c 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -300,9 +300,10 @@ fn import_pages_mapped( ocgs: Vec::new(), }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); + let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); for (k, v) in src_dict.iter() { - if SKIP_ON_PAGE.contains(&k.as_slice()) { + if SKIP_ON_PAGE.contains(&k.as_slice()) || (k.as_slice() == b"Resources" && resources.is_some()) { continue; } if k.as_slice() == b"Annots" { @@ -364,6 +365,10 @@ fn import_pages_mapped( } d.set(k.clone(), copier.copy_value(dst, v, None)); } + if let Some(r) = resources { + let r = copier.copy_value(dst, &r, None); + d.set(b"Resources".to_vec(), r); + } for k in INHERITABLE { if !d.contains(k) && let Some(v) = inherited.get(k) @@ -398,6 +403,56 @@ fn import_pages_mapped( Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map, renamed_fields }) } +/// A page's `/Resources` with the names that only its page-tree ancestors list, or `None` when +/// they add nothing to the dictionary it would have anyway (its own, or else its nearest +/// ancestor's). Readers (ours included) look a resource name up the page and then up the tree, +/// so a page that lists its font but inherits its images still shows them. A copy hangs from +/// another tree, so it must list them itself or it draws nothing of them (#1628). The page's +/// entry wins over an ancestor's, and a nearer ancestor's over a farther one's. +fn resources_with_ancestors(src: &Document, page: &Dict) -> Option { + let mut merged = page.get(b"Resources").map(|r| src.dict(r).unwrap_or_default()); + let mut added = false; + let mut seen = std::collections::HashSet::new(); + let mut parent = page.reference(b"Parent"); + // Bounded: a damaged tree's `/Parent` links can loop or run deep. + while let Some(r) = parent + && seen.insert(r) + && seen.len() <= 256 + { + let node = src.get(r); + let Some(node) = node.as_dict() else { break }; + parent = node.reference(b"Parent"); + let Some(theirs) = node.get(b"Resources").and_then(|r| src.dict(r)) else { continue }; + let Some(mine) = merged.as_mut() else { + merged = Some(theirs); + continue; + }; + for (category, entries) in theirs.iter() { + match (mine.get(category).map(|c| src.dict(c)), src.dict(entries)) { + (None, _) => { + mine.set(category.clone(), entries.clone()); + added = true; + } + (Some(Some(mut names)), Some(entries)) => { + let before = names.len(); + for (name, value) in entries.iter() { + if !names.contains(name) { + names.set(name.clone(), value.clone()); + } + } + if names.len() > before { + mine.set(category.clone(), Object::Dict(names)); + added = true; + } + } + // `/ProcSet` and the like: the page's own value stands. + _ => {} + } + } + } + merged.filter(|_| added).map(Object::Dict) +} + /// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree). fn named_dest(src: &Document, name: &[u8]) -> Option { let catalog = src.get(src.root()?).as_dict().cloned()?; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index a47adb429c..7b366bad9d 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -21,6 +21,7 @@ mod outline; mod pdfx; mod prune; pub mod view; +mod xmp; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; @@ -40,7 +41,8 @@ pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; pub enum OrganizeError { #[error("the document has no page tree")] NoPageTree, - #[error("page {0} does not exist")] + /// Holds the 0-based index; the message names the 1-based page a user counts (#1550). + #[error("page {} does not exist", .0.saturating_add(1))] NoSuchPage(usize), #[error("a document must keep at least one page")] WouldRemoveAllPages, @@ -158,11 +160,11 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu } let all = walk(doc)?; check(indices, all.len())?; - let delta = (degrees / 90) * 90; + let delta = degrees.rem_euclid(360); 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); + let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } Ok(()) @@ -503,12 +505,14 @@ pub fn info(doc: &Document, key: &str) -> Option { /// 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. +/// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer +/// in the XMP packet, and viewers that read XMP first show its value: the packet's property is +/// changed (or removed) to match (see `xmp`). 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))); - set_info_entry(doc, key.as_bytes(), entry) + set_info_entry(doc, key.as_bytes(), entry)?; + xmp::sync_info(doc, key, value) } /// Set (or remove, with `None`) a document-information entry of any type. diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs index 3241d43031..1aa4fc503c 100644 --- a/crates/organize/src/pdfx.rs +++ b/crates/organize/src/pdfx.rs @@ -191,7 +191,7 @@ fn unescape(s: &str) -> String { } /// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out. -fn esc(s: &str) -> String { +pub(crate) fn esc(s: &str) -> String { s.chars() .filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}')) .collect::() diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 2652386513..b5f5e65ffe 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -14,6 +14,8 @@ use crate::OrganizeError; /// Form XObjects nested deeper than this are a broken file, not a reason to hang. const MAX_FORM_DEPTH: u8 = 8; +/// Count every resource object and direct child; a graph beyond this is left unpruned. +const MAX_GLYPH_RESOURCE_WORK: usize = 10_000; /// The dictionary a set of drawn names belongs to. #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -42,6 +44,12 @@ struct Pruner<'a> { /// past the depth limit, Type 3 glyphs drawing through them). They are left untouched: /// pruning must never drop an XObject something still draws. unsure: HashSet, + /// An overlarge glyph-resource graph is not safe to prune at all. + glyph_budget_exhausted: bool, + glyph_work_left: usize, + glyph_seen: HashSet, + /// An indirect font's explicit resources have one owner regardless of which page uses it. + glyph_fonts: HashSet, } impl Pruner<'_> { @@ -52,14 +60,106 @@ impl Pruner<'_> { } } - /// True when `resources` lists a Type 3 font without its own `/Resources`: its glyph - /// procedures draw through these resources, and they are not followed here. - fn has_inheriting_type3(&self, resources: &Dict) -> bool { - let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return false }; - let Some(fonts) = fonts.as_dict() else { return false }; - fonts - .iter() - .any(|(_, f)| self.doc.resolve(f).as_dict().is_some_and(|fd| fd.name(b"Subtype") == Some(b"Type3") && fd.get(b"Resources").is_none())) + fn glyph_work(&mut self, count: usize) -> bool { + if !self.glyph_budget_exhausted + && let Some(left) = self.glyph_work_left.checked_sub(count) + { + self.glyph_work_left = left; + return true; + } + self.glyph_budget_exhausted = true; + false + } + + /// Walk one object's direct children without cloning them or recursing. Referenced objects + /// go on a separate work list, so their borrowed dictionaries stay alive during this pass. + fn protect_glyph_tree(&mut self, object: &Object, referenced: &mut Vec) { + if !self.glyph_work(1) { + return; + } + let mut pending = vec![object]; + while let Some(object) = pending.pop() { + match object { + Object::Ref(r) => { + // A glyph's forms, patterns and soft masks can share either the XObject + // dictionary itself or a resource owner with content we do interpret. + self.unsure.insert(XKey::XObject(*r)); + self.unsure.insert(XKey::Resources(*r)); + self.unsure.insert(XKey::Page(*r)); + if self.glyph_seen.insert(*r) { + referenced.push(*r); + } + } + Object::Array(array) => { + if !self.glyph_work(array.len()) { + return; + } + pending.extend(array); + } + Object::Dict(_) | Object::Stream(_) => { + if let Some(dict) = object.as_dict() { + if !self.glyph_work(dict.len()) { + return; + } + pending.extend(dict.iter().map(|(_, value)| value)); + } + } + _ => {} + } + } + } + + /// Glyph programs are not interpreted here. Keep every resource owner reachable from their + /// resource graph, including nested forms, patterns and soft masks. Data bytes are never + /// decoded; references are visited once and all direct children count against the budget. + fn protect_glyph_object(&mut self, object: &Object) { + let mut referenced = Vec::new(); + self.protect_glyph_tree(object, &mut referenced); + while !self.glyph_budget_exhausted { + let Some(r) = referenced.pop() else { break }; + let object = self.doc.get(r); + self.protect_glyph_tree(&object, &mut referenced); + } + } + + fn protect_type3_resources(&mut self, resources: &Dict, owner: Owner) { + if self.glyph_budget_exhausted { + return; + } + let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return }; + let Some(fonts) = fonts.as_dict() else { return }; + // Bound discovery too, but don't spend the glyph-graph budget repeatedly examining an + // ordinary shared font catalog on every page of a large document. + if fonts.len() > MAX_GLYPH_RESOURCE_WORK { + self.glyph_budget_exhausted = true; + return; + } + for (_, font) in fonts.iter() { + if self.glyph_budget_exhausted { + return; + } + let font_ref = font.as_ref(); + let resolved = self.doc.resolve(font); + let Some(font) = resolved.as_dict().filter(|f| f.name(b"Subtype") == Some(b"Type3")) else { continue }; + if let Some(glyph_resources) = font.get(b"Resources") { + if let Some(r) = font_ref + && !self.glyph_fonts.insert(r) + { + continue; + } + self.protect_glyph_object(glyph_resources); + } else { + self.distrust(resources, owner); + // This root dictionary is already owned by the content caller. Only its + // referenced descendants can share additional owners elsewhere in the file. + for (_, value) in resources.iter() { + self.protect_glyph_object(value); + if self.glyph_budget_exhausted { + return; + } + } + } + } } /// The `/XObject` dictionary of `resources`, with the key its drawn names belong to. @@ -77,10 +177,11 @@ impl Pruner<'_> { /// Record every XObject name `data` draws with `Do`, descending into form XObjects. fn content(&mut self, data: &[u8], resources: &Dict, owner: Owner, depth: u8) { + self.protect_type3_resources(resources, owner); let Some((xobjects, key)) = self.xobjects(resources, owner) else { return; }; - if depth > MAX_FORM_DEPTH || self.has_inheriting_type3(resources) { + if depth > MAX_FORM_DEPTH { self.unsure.insert(key); return; } @@ -113,7 +214,10 @@ impl Pruner<'_> { match s.decoded() { Ok(data) => self.content(&data, &inner, inner_owner, depth + 1), // What the form draws is unknown: keep everything it could reach. - Err(_) => self.distrust(&inner, inner_owner), + Err(_) => { + self.protect_type3_resources(&inner, inner_owner); + self.distrust(&inner, inner_owner); + } } } } @@ -202,7 +306,15 @@ fn filter_at(doc: &mut Document, key: XKey, names: &HashSet>) -> Result< /// Drop the page-resource entries the document's pages never draw. Returns how many were removed. pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result { let pages = crate::walk(doc)?; - let mut pruner = Pruner { doc, used: HashMap::new(), unsure: HashSet::new() }; + let mut pruner = Pruner { + doc, + used: HashMap::new(), + unsure: HashSet::new(), + glyph_budget_exhausted: false, + glyph_work_left: MAX_GLYPH_RESOURCE_WORK, + glyph_seen: HashSet::new(), + glyph_fonts: HashSet::new(), + }; for (page, attrs) in &pages { let Some(res) = attrs.get(b"Resources") else { continue }; let Some((res_dict, owner)) = resolve_resources(pruner.doc, res, *page) else { continue }; @@ -211,10 +323,16 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => pruner.distrust(&res_dict, owner), + None => { + pruner.protect_type3_resources(&res_dict, owner); + pruner.distrust(&res_dict, owner); + } } } - let Pruner { used, unsure, .. } = pruner; + let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner; + if glyph_budget_exhausted { + return Ok(0); + } let mut removed = 0; for (key, names) in used { if !unsure.contains(&key) { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index b4aae6b576..e07af72a1c 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -28,6 +28,20 @@ fn rotate_rejects_angles_that_are_not_multiples_of_90() { assert_eq!(page_rotation(&doc, 0).unwrap(), 0); } +#[test] +fn rotate_handles_large_quarter_turns() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(90, large, 90), (-90, -large, 270), (large, 90, 90), (-large, -90, 270), (180, large - 90, 90)] { + for inherited in [false, true] { + let mut doc = open(fixture()); + let target = if inherited { pages_root(&doc).unwrap() } else { pages(&doc).unwrap()[0].obj }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Int(current))).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), expected); + } + } +} + /// 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 { @@ -215,6 +229,7 @@ fn cannot_delete_every_page_or_missing_pages() { 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_eq!(OrganizeError::NoSuchPage(3).to_string(), "page 4 does not exist", "the message counts pages from 1 (#1550)"); assert!(!doc.is_modified(), "failed operations leave the document untouched"); } @@ -252,6 +267,101 @@ fn document_info_edits_round_trip_with_unicode() { assert_eq!(info(&doc, "Producer"), None); } +/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the +/// same title and author in `/Info`, as LibreOffice writes them. +fn doc_with_xmp(xmp: &[u8]) -> Document { + let b = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + body("X"), + "<< /Title (Untitled 1) /Author (Old Author) >>".into(), + ], + "/Root 1 0 R /Info 5 0 R", + ); + let mut doc = open(b); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), Object::name("XML")); + let m = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, xmp))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m))).unwrap(); + full_roundtrip(&doc) +} + +/// The catalog's metadata stream. +fn xmp_stream(doc: &Document) -> Option { + match &*doc.get(catalog(doc).reference(b"Metadata")?) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn xmp_text(doc: &Document) -> Option { + xmp_stream(doc).map(|s| String::from_utf8(s.decoded().unwrap()).unwrap()) +} + +/// The values the XMP packet gives a property: the items of its rdf:Alt or rdf:Seq, else its text. +fn xmp_values(xmp: &str, name: &str) -> Vec { + let Some(start) = xmp.find(&format!("<{name}>")) else { return Vec::new() }; + let end = xmp[start..].find(&format!("")).unwrap() + start; + let inner = &xmp[start + name.len() + 2..end]; + if !inner.contains("').unwrap() + 1..li.find("").unwrap()].to_string()).collect() +} + +const LIBREOFFICE_XMP: &str = "\n\ +\n \n\ + Writer 1.0\n\ + \n\ + Untitled 1\n\ + Old Author\n\ + Old subject\n\ + \n \n\n"; + +#[test] +fn document_info_edits_update_the_xmp_packet() { + // Viewers that read XMP first (pdf.js takes dc:title) showed the old title after a change. + let mut doc = doc_with_xmp(LIBREOFFICE_XMP.as_bytes()); + assert_eq!(xmp_values(&xmp_text(&doc).unwrap(), "dc:title"), ["Untitled 1"]); + set_info(&mut doc, "Title", "Sales Guide v1.0 & ").unwrap(); + set_info(&mut doc, "Author", "Demo Consulting").unwrap(); + set_info(&mut doc, "Subject", "").unwrap(); + set_info(&mut doc, "Keywords", "alpha, beta").unwrap(); + set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & ")); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!(xmp_values(&xmp, "dc:title"), ["Sales Guide v1.0 & <draft>"]); + assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]); + assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}"); + assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]); + assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); + assert!(xmp.starts_with(""), "{xmp}"); + // Stored unfiltered, still typed as metadata. + let dict = xmp_stream(&doc).unwrap().dict; + assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); +} + +#[test] +fn document_info_edits_leave_files_without_usable_xmp_alone() { + // No packet: none is added. + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "New").unwrap(); + assert_eq!(xmp_text(&save_and_reopen(&doc)), None); + // A packet that isn't UTF-8 is not rewritten. + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + set_info(&mut doc, "Title", "New").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("New")); + assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1); +} + #[test] fn successive_incremental_saves_stack_revisions() { let mut doc = open(fixture()); @@ -461,6 +571,40 @@ fn inherited_attributes_travel_with_copied_pages() { assert_eq!(hay.pages().first().unwrap().render_dimensions(), (250.0, 500.0), "rotated 90°"); } +#[test] +fn resources_split_between_page_and_tree_travel_together() { + // The page names its own font but draws a form that only the page-tree node lists. Readers + // (ours included) look a name up the whole chain, so the page shows; a copy that kept only + // the page's own dictionary drew nothing (#1628). + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] /Resources << /XObject << /X0 5 0 R >> /Font << /F1 6 0 R /F2 6 0 R >> >> >>" + .into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F2 7 0 R >> >> >>".into(), + { + let s = "q 100 0 0 100 50 50 cm /X0 Do Q BT /F2 12 Tf (x) Tj ET"; + format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len()) + }, + { + let s = "0 0 1 rg 0 0 1 1 re f"; + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Length {} >>\nstream\n{s}\nendstream", s.len()) + }, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(), + ]; + let src = open(build(&b, "/Root 1 0 R")); + let out = full_roundtrip(&combine(&[("S", &src), ("S again", &src)]).unwrap()); + for i in 0..2 { + let res = out.dict(page_dict(&out, i).get(b"Resources").unwrap()).unwrap(); + let entry = |cat: &[u8], name: &[u8]| res.get(cat).and_then(|c| out.dict(c)).and_then(|d| d.get(name).map(|v| out.resolve(v))); + let form = entry(b"XObject", b"X0").expect("the tree's form comes along"); + assert_eq!(form.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Form"[..])); + let font = |name: &[u8]| entry(b"Font", name).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))); + assert_eq!(font(b"F1").as_deref(), Some(&b"Helvetica"[..]), "a name only the tree has is added"); + assert_eq!(font(b"F2").as_deref(), Some(&b"Courier"[..]), "the page's own entry wins over the tree's"); + } +} + #[test] fn links_are_rewired_to_copied_pages_or_dropped() { let a = doc_a(); @@ -1703,6 +1847,215 @@ fn extract_keeps_images_type3_glyphs_may_draw() { assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources"); } +/// The font has its own resources, but its XObject dictionary is also used by the page. +/// Looking only at page `Do` operators must not discard the image painted by a glyph. +fn doc_type3_shared_xobjects(font_resources: &str) -> Document { + let objects: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources 5 0 R >>".into(), + raw("q 30 0 0 30 10 10 cm /ImA Do Q BT /T3 50 Tf 50 80 Td (A) Tj ET"), + "<< /XObject 6 0 R /Font << /T3 7 0 R >> >>".into(), + "<< /ImA 9 0 R /ImB 10 0 R /Unused 11 0 R >>".into(), + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << /A 8 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [1] /Resources {font_resources} >>" + ), + raw("1 0 0 0 1 1 d1 /ImB Do"), + image(1, 1, 1), + image(1, 1, 1), + image(1, 1, 2000), + ]; + open(build(&objects, "/Root 1 0 R")) +} + +#[test] +fn extract_keeps_shared_xobjects_used_by_type3_own_resources() { + for resources in ["<< /XObject 6 0 R >>", "5 0 R"] { + let src = doc_type3_shared_xobjects(resources); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "the Type 3 glyph's image must survive extraction: {resources}"); + } +} + +#[test] +fn extract_still_prunes_page_xobjects_when_type3_resources_are_separate() { + let src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "only the page's unused entries are removed"); + let resources = part.resolve(page_dict(&part, 0).get(b"Resources").unwrap()); + let fonts = part.resolve(resources.as_dict().unwrap().get(b"Font").unwrap()); + let font = part.resolve(fonts.as_dict().unwrap().get(b"T3").unwrap()); + let resources = part.resolve(font.as_dict().unwrap().get(b"Resources").unwrap()); + let xobjects = part.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + assert!(xobjects.as_dict().unwrap().contains(b"ImB"), "the separate glyph resource survives the full rewrite"); +} + +fn doc_type3_glyph_form_with_shared_resources(cycle: bool) -> Document { + let mut doc = doc_type3_shared_xobjects("<< >>"); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject".to_vec(), Object::Ref(ObjRef::new(6, 0))); + let mut form = pdfcraft_cos::Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(1), Object::Int(1)])); + form.set(b"Resources".to_vec(), Object::Dict(resources)); + let form = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(form, b"/ImB Do".to_vec()))); + let mut xobjects = pdfcraft_cos::Dict::new(); + xobjects.set(b"Fm".to_vec(), Object::Ref(form)); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + doc.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + doc.set(ObjRef::new(8, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"1 0 0 0 1 1 d1 /Fm Do".to_vec()))); + if cycle { + doc.update_dict(ObjRef::new(6, 0), |xobjects| xobjects.set(b"Again".to_vec(), Object::Ref(form))).unwrap(); + } + full_roundtrip(&doc) +} + +#[test] +fn extract_keeps_shared_resources_reached_through_a_type3_glyph_form() { + for cycle in [false, true] { + let src = doc_type3_glyph_form_with_shared_resources(cycle); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "a glyph's nested form draws ImB; resource cycle: {cycle}"); + } +} + +#[test] +fn extract_keeps_an_indirect_resource_owner_shared_with_a_type3_font() { + let mut src = doc_type3_shared_xobjects("5 0 R"); + let xobjects = src.get(ObjRef::new(6, 0)).as_dict().unwrap().clone(); + src.update_dict(ObjRef::new(5, 0), |resources| resources.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap(); + let src = full_roundtrip(&src); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "the resource owner itself is shared, with inline XObjects"); +} + +#[test] +fn extract_skips_pruning_when_direct_glyph_resource_children_exceed_the_budget() { + let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"); + let font = src.get(ObjRef::new(7, 0)); + let mut resources = font.as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let mut unknown = vec![Object::Null; 20_000]; + unknown.push(Object::Ref(ObjRef::new(6, 0))); + resources.set(b"Unknown".to_vec(), Object::Array(unknown)); + src.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let src = full_roundtrip(&src); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert_eq!(xobject_names(&part, 0), xobject_names(&src, 0), "an incomplete glyph resource walk cannot authorize pruning any owners"); +} + +fn doc_type3_resources_of_undecodable_content(form: bool) -> Document { + let mut doc = doc_type3_shared_xobjects("<< /XObject 6 0 R >>"); + doc.update_dict(ObjRef::new(5, 0), |resources| { + resources.remove(b"Font"); + }) + .unwrap(); + doc.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec()))); + let mut fonts = pdfcraft_cos::Dict::new(); + fonts.set(b"T3".to_vec(), Object::Ref(ObjRef::new(7, 0))); + let mut glyph_resources = pdfcraft_cos::Dict::new(); + glyph_resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut unreadable = pdfcraft_cos::Dict::new(); + unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + if form { + unreadable.set(b"Type".to_vec(), Object::name("XObject")); + unreadable.set(b"Subtype".to_vec(), Object::name("Form")); + unreadable.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(200)])); + unreadable.set(b"Resources".to_vec(), Object::Dict(glyph_resources.clone())); + } + let unreadable = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(unreadable, b"BT /T3 50 Tf 50 80 Td (A) Tj ET".to_vec()))); + let (contents, resources) = if form { + let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/Fm Do".to_vec()))); + let mut xobjects = pdfcraft_cos::Dict::new(); + xobjects.set(b"Fm".to_vec(), Object::Ref(unreadable)); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + (contents, resources) + } else { + (unreadable, glyph_resources) + }; + let mut page = pdfcraft_cos::Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(ObjRef::new(2, 0))); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(page); + doc.update_dict(ObjRef::new(2, 0), |pages| { + pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(page)])); + pages.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + full_roundtrip(&doc) +} + +#[test] +fn extract_keeps_type3_glyph_resources_of_an_undecodable_page() { + let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(false), &[0, 1]).unwrap()); + assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable page's font shares a different page's XObject dictionary"); + let contents = part.resolve(page_dict(&part, 1).get(b"Contents").unwrap()); + let Object::Stream(contents) = &*contents else { panic!("content stream") }; + assert_eq!(contents.dict.name(b"Filter"), Some(b"NoSuchFilter".as_slice())); + assert_eq!(contents.raw.as_ref(), b"BT /T3 50 Tf 50 80 Td (A) Tj ET"); +} + +#[test] +fn extract_keeps_type3_glyph_resources_of_an_undecodable_form() { + let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(true), &[0, 1]).unwrap()); + assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable form's font shares a different page's XObject dictionary"); +} + +#[test] +fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() { + let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"); + src.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec()))); + let page = src.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let mut kids = vec![Object::Ref(ObjRef::new(3, 0))]; + for _ in 1..4_000 { + kids.push(Object::Ref(src.add(page.clone()))); + } + src.update_dict(ObjRef::new(2, 0), |pages| { + pages.set(b"Kids".to_vec(), Object::Array(kids)); + pages.set(b"Count".to_vec(), Object::Int(4_000)); + }) + .unwrap(); + let src = full_roundtrip(&src); + let part = full_roundtrip(&extract_pages(&src, &(0..4_000).collect::>()).unwrap()); + assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once"); +} + +#[test] +#[ignore = "requires the external Poppler pdftoppm oracle"] +fn extract_type3_shared_xobjects_matches_poppler_pixels() { + let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fixtures = [ + doc_type3_shared_xobjects("<< /XObject 6 0 R >>"), + doc_type3_shared_xobjects("5 0 R"), + doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"), + doc_type3_glyph_form_with_shared_resources(false), + doc_type3_glyph_form_with_shared_resources(true), + ]; + for (i, src) in fixtures.into_iter().enumerate() { + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + let render = |doc: &Document, name: &str| { + let path = dir.join(format!("{i}-{name}.pdf")); + std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap(); + let output = std::process::Command::new("pdftoppm").args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); + output.stdout + }; + let before = render(&src, "source"); + let after = render(&part, "extracted"); + assert!(before.starts_with(b"P5\n200 200\n255\n")); + assert!(before.iter().filter(|&&v| v < 200).count() > 2500, "the source contains a drawn glyph"); + assert_eq!(after, before, "extraction must keep every rendered pixel in fixture {i}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + /// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the /// second widget joins that array, and the field keeps every widget it had. #[test] diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs new file mode 100644 index 0000000000..3efa8d2653 --- /dev/null +++ b/crates/organize/src/xmp.rs @@ -0,0 +1,251 @@ +//! Keeping the catalog's XMP metadata (`/Root /Metadata`, ISO 32000-2 §14.3.2) in step with the +//! document information when Document Properties ▸ Description changes it. +//! +//! Files that carry XMP state the title and author twice, and viewers that read XMP first show +//! the packet's value, so changing only `/Info` leaves the file disagreeing with itself. Each +//! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same +//! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject → +//! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer → +//! `pdf:Producer`. +//! +//! The packet is edited as text, touching only the property that changed: everything else in it +//! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets +//! none, and a packet this can't edit safely (not UTF-8, no `rdf:RDF`, too large to decode) is +//! left as it was. + +use pdfcraft_cos::{Document, Object, Stream}; + +use crate::OrganizeError; +use crate::pdfx::esc; + +/// Decoding limit for an XMP packet (they are a few kB; padding rarely takes them past 100 kB). +const MAX_XMP: usize = 16 << 20; + +const DC: &str = "http://purl.org/dc/elements/1.1/"; +const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; +const XMP: &str = "http://ns.adobe.com/xap/1.0/"; + +/// How an XMP property holds its value. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shape { + /// A language alternative: `rdf:Alt` with an `x-default` item. + LangAlt, + /// An ordered array: `rdf:Seq` (the authors). + Seq, + /// Plain text. + Text, +} + +/// The XMP property (qualified name, namespace) that mirrors a document information key. +fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { + Some(match key { + "Title" => ("dc:title", DC, Shape::LangAlt), + "Author" => ("dc:creator", DC, Shape::Seq), + "Subject" => ("dc:description", DC, Shape::LangAlt), + "Keywords" => ("pdf:Keywords", PDF, Shape::Text), + "Creator" => ("xmp:CreatorTool", XMP, Shape::Text), + "Producer" => ("pdf:Producer", PDF, Shape::Text), + _ => return None, + }) +} + +/// After the information entry `key` became `value` (empty: removed), make the catalog's XMP +/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets +/// that can't be edited safely. +pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + let Some((name, ns, shape)) = property(key) else { return Ok(()) }; + let Some(root) = doc.root() else { return Ok(()) }; + // A metadata stream is always indirect (§7.3.8); anything else is left alone. + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return Ok(()) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; + let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. + let mut dict = stream.dict.clone(); + for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { + dict.remove(k); + } + doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); + Ok(()) +} + +/// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is +/// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to. +fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option { + let element = (!value.is_empty()).then(|| match shape { + Shape::LangAlt => format!("<{name}>{}", esc(value)), + Shape::Seq => format!("<{name}>{}", esc(value)), + Shape::Text => format!("<{name}>{}", esc(value)), + }); + // The element form, `…` (or ``): replaced where it stands, so + // it keeps the namespace declaration of the description around it. + if let Some((start, end)) = element_span(xmp, name) { + let mut out = String::with_capacity(xmp.len()); + out.push_str(xmp.get(..start)?); + out.push_str(element.as_deref().unwrap_or_default()); + out.push_str(xmp.get(end..)?); + return Some(out); + } + // The attribute form, `pdf:Keywords="…"` on an `rdf:Description`: only simple text can be + // written there; a structured value moves to an element of its own. + if let Some((start, value_start, value_end, quote)) = attribute_span(xmp, name) { + let mut out = String::with_capacity(xmp.len()); + if shape == Shape::Text && !value.is_empty() { + let escaped = if quote == '"' { esc(value) } else { esc(value).replace('\'', "'") }; + out.push_str(xmp.get(..value_start)?); + out.push_str(&escaped); + out.push_str(xmp.get(value_end..)?); + return Some(out); + } + out.push_str(xmp.get(..start)?); + out.push_str(xmp.get(value_end + 1..)?); + return match element { + Some(e) => insert_description(&out, name, ns, &e), + None => Some(out), + }; + } + insert_description(xmp, name, ns, element.as_deref()?) +} + +/// `xmp` with a new `rdf:Description` holding `element` (and declaring its namespace) as the last +/// description of the packet. +fn insert_description(xmp: &str, name: &str, ns: &str, element: &str) -> Option { + let prefix = name.split(':').next()?; + let at = xmp.rfind("")?; + // Every description of a packet describes the same resource (XMP: one `rdf:about` value). + let about = attribute_span(xmp, "rdf:about").and_then(|(_, s, e, _)| xmp.get(s..e)).unwrap_or_default().replace('"', """); + let mut out = String::with_capacity(xmp.len() + element.len() + 128); + out.push_str(xmp.get(..at)?); + out.push_str(&format!("{element}\n")); + out.push_str(xmp.get(at..)?); + Some(out) +} + +/// Where the element `name` is in `xmp`, from its `<` to past its end tag (or past `/>`). +fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { + let open = format!("<{name}"); + let mut from = 0; + while let Some(i) = xmp.get(from..).and_then(|s| s.find(&open)).map(|i| i + from) { + let after = i + open.len(); + from = after; + // `' | '/') => {} + Some(c) if c.is_whitespace() => {} + _ => continue, + } + let tag_end = after + xmp.get(after..)?.find('>')?; + if xmp.get(..tag_end)?.ends_with('/') { + return Some((i, tag_end + 1)); + } + let close = format!(""); + let end = tag_end + xmp.get(tag_end..)?.find(&close)? + close.len(); + return Some((i, end)); + } + None +} + +/// Where the attribute `name` is in `xmp`: its start (including the whitespace before it), the +/// start and end of its value, and its quote character. Only matches inside a start tag. +fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { + let mut from = 0; + while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { + from = i + name.len(); + let before = xmp.get(..i)?; + // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). + if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() { + continue; + } + let rest = xmp.get(from..)?; + let trimmed = rest.trim_start(); + let Some(after_eq) = trimmed.strip_prefix('=') else { continue }; + let after_eq = after_eq.trim_start(); + let Some(quote) = after_eq.chars().next().filter(|c| matches!(c, '"' | '\'')) else { continue }; + let value_start = xmp.len() - after_eq.len() + 1; + let value_end = value_start + xmp.get(value_start..)?.find(quote)?; + let start = before.trim_end().len(); + return Some((start, value_start, value_end, quote)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shape of a LibreOffice export: one description per schema, element forms. + const PACKET: &str = "\n\ +\n \n\ + \n Writer 1.0\n \n\ + \n\ + Untitled 1\n\ + Old Author\n\ + Old subject\n\ + \n \n\n"; + + #[test] + fn element_values_are_replaced_in_place() { + let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); + assert!(out.contains("Guide & <draft>\n"), "{out}"); + assert!(!out.contains("Untitled 1")); + // Everything else is untouched. + assert_eq!(out.replace("Guide & <draft>", "Untitled 1"), PACKET); + let out = set_property(PACKET, "dc:creator", DC, Shape::Seq, "Demo Consulting").unwrap(); + assert!(out.contains("Demo Consulting"), "{out}"); + } + + #[test] + fn an_empty_value_removes_the_property() { + let out = set_property(PACKET, "dc:description", DC, Shape::LangAlt, "").unwrap(); + assert!(!out.contains("dc:description"), "{out}"); + assert!(out.contains("Untitled 1") && out.contains("Old Author")); + // Nothing to remove: no change. + assert_eq!(set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, ""), None); + } + + #[test] + fn a_missing_property_gets_a_description_of_its_own() { + let out = set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, "alpha, beta").unwrap(); + let added = + "alpha, beta\n"; + assert!(out.find(added).is_some_and(|at| at > out.find("").unwrap() && at < out.find("").unwrap()), "{out}"); + assert_eq!(out.replacen(added, "", 1), PACKET); + // A packet with no rdf:RDF to add to is left alone. + assert_eq!(set_property("", "pdf:Keywords", PDF, Shape::Text, "alpha"), None); + } + + #[test] + fn attribute_values_are_rewritten_where_they_stand() { + let packet = ""; + let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "Writer's tool").unwrap(); + assert!(out.contains("xmp:CreatorTool='Writer's tool'"), "{out}"); + // A title can't be an attribute: it moves to an element, in a description for the same resource. + let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "Guide").unwrap(); + assert!(!out.contains("dc:title=\"Odd\""), "{out}"); + assert!(out.contains(""), "{out}"); + let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "").unwrap(); + assert!(out.contains("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" dc:title=\"Odd\"/>"), "{out}"); + } + + #[test] + fn lookalike_names_and_text_are_not_matched() { + let packet = "keep\ + say dc:title=\"no\""; + let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "New").unwrap(); + assert!(out.contains("keep") && out.contains("say dc:title=\"no\""), "{out}"); + assert!(out.contains("New"), "{out}"); + } + + #[test] + fn hostile_packets_do_not_panic() { + for packet in ["", "<", "", " two[0].placed[0].matrix.0[4]); // Vertical order and borders. - let v = - layout(&sizes, &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, order: PageOrder::Vertical, border: true, auto_rotate: false })) - .unwrap(); + let v = layout( + &sizes, + &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false }), + ) + .unwrap(); assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]); assert_eq!(v[0].borders.len(), 3); // Auto-rotate turns a landscape page in a portrait cell. @@ -168,6 +170,28 @@ fn multiple_pages_per_sheet() { assert_eq!(r[0].placed[1].matrix.0[0], 0.0, "rotated"); } +#[test] +fn custom_pages_per_sheet_never_overfill_the_grid() { + // Three per sheet draws on a 2 × 2 grid, five on 3 × 2: the spare cells stay blank (#1336). + let sizes = vec![(200.0, 300.0); 7]; + let pages = |per: usize| sheet_pages(&layout(&sizes, &settings((0..7).collect(), Layout::multiple(per))).unwrap()); + assert_eq!(pages(3), [vec![0, 1, 2], vec![3, 4, 5], vec![6]]); + assert_eq!(pages(5), [vec![0, 1, 2, 3, 4], vec![5, 6]]); + assert_eq!(pages(7), [vec![0, 1, 2, 3, 4, 5, 6]]); + assert_eq!(pages(4), [vec![0, 1, 2, 3], vec![4, 5, 6]]); + assert_eq!(pages(2), [vec![0, 1], vec![2, 3], vec![4, 5], vec![6]]); + // Cut-stack piles take the requested count too. + let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false }; + assert_eq!(sheet_pages(&layout(&sizes, &settings((0..7).collect(), stacked)).unwrap()), [vec![0, 3, 6], vec![1, 4], vec![2, 5]]); + // A count larger than the grid is capped by the grid; no pages per sheet is refused. + let grid = |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + assert_eq!(sheet_pages(&layout(&sizes[..3], &settings(vec![0, 1, 2], grid(9))).unwrap()), [vec![0, 1], vec![2]]); + assert!(matches!(layout(&sizes, &settings(vec![0], grid(0))), Err(PrintError::Invalid(_)))); + // The imposed PDF has one page per sheet. + let out = impose(&fixture(7), &settings((0..7).collect(), Layout::multiple(3))).unwrap(); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; @@ -309,7 +333,7 @@ fn driverless_printers_join_the_list_without_a_queue() { } fn cut_stack(cols: usize, rows: usize) -> Layout { - Layout::Multiple { cols, rows, order: PageOrder::CutStack, border: false, auto_rotate: false } + Layout::Multiple { cols, rows, per_sheet: cols.saturating_mul(rows), order: PageOrder::CutStack, border: false, auto_rotate: false } } #[test] @@ -362,7 +386,7 @@ fn cutting_and_stacking_recovers_every_selected_page() { fn multiple_rejects_hostile_grids_without_panicking() { let sizes = [(200.0, 300.0)]; for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] { - let mode = Layout::Multiple { cols, rows, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false }; assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_)))); } let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) }; diff --git a/crates/redact/README.md b/crates/redact/README.md index c156ad0fb6..4a569eaa18 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -36,6 +36,11 @@ leave it unredacted. Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; +a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's +`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed +XObject reachable. Pages that aren't redacted are left untouched. + ## Remove hidden information / Sanitize ```rust diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index d708808328..c76e119953 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -23,7 +23,7 @@ use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -use crate::{Report, image, tags}; +use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; const MAX_DEPTH: usize = 12; @@ -62,6 +62,10 @@ pub(crate) struct Output { /// can unlink the original from the resource dictionary (a full save keeps whatever is still /// referenced, which would otherwise retain the removed image or form bytes). pub replaced: Vec>, + /// The original objects behind `replaced`, nested scopes included. Other resource + /// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a + /// form has none of its own): [`unlink_doomed`] removes those entries. + pub doomed: Vec, /// Glyphs / inline images still overlapping a region (verify mode). pub residue: usize, /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was @@ -203,6 +207,117 @@ pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { } } +/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or +/// through a form that has no /Resources of its own (such a form draws through its caller's). +/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). +fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { + let joined = Pieces::join(content); + let ops = joined.parse().ops; + for op in ops.iter().filter(|op| op.is("Do")) { + let Some(n) = op.name(0) else { continue }; + if n == name { + return Some(true); + } + let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + continue; + } + if depth >= MAX_DEPTH { + return None; + } + let Ok(data) = s.decoded() else { return None }; + match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { + Some(false) => {} + other => return other, + } + } + Some(false) +} + +/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource +/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the +/// originals stay reachable, and so stay in a full-rewrite save, when: +/// - the page inherited its /Resources from a /Pages node, which still lists them; +/// - several pages share one resource dictionary, and another page still refers to it; +/// - a form without /Resources drew them through the page's dictionary. +/// +/// An entry goes unless a page that draws through that dictionary still draws the name (the same +/// image shown on another page, or again outside a mark). A dictionary that loses an entry is +/// replaced by a private copy on its node, so a dictionary object shared with other nodes is +/// left as it is for them. Fails closed when a page's use of a name cannot be told. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> { + if doomed.is_empty() { + return Ok(()); + } + let doomed: HashSet = doomed.iter().copied().collect(); + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; + // Every page-tree node with a /Resources of its own, and the pages that draw through it + // (the nearest such node above a page, or the page itself). Cycles are cut as in + // `pdfcraft_model::pages`. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + 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(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + for (node, users) in holders { + let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let mut xo = res_dict(doc, &res, b"XObject"); + let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() { + continue; + } + let mut contents = Vec::with_capacity(users.len()); + for user in &users { + let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); + // A page whose content cannot be read may still draw the name: fail closed. + contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); + } + let mut pruned = false; + for n in names { + let mut drawn = Some(false); + for content in &contents { + drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); + if drawn != Some(false) { + break; + } + } + match drawn { + Some(true) => {} + Some(false) => { + xo.remove(&n); + pruned = true; + } + None => return Err(RedactError::Residue(1)), + } + } + if pruned { + let mut res = res; + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + Ok(()) +} + /// The clip that hides everything inside each region: one even-odd clip per region (a big /// rectangle with the region cut out), in the current user space. Returns `None` when the CTM /// can't be inverted (nothing is painted then anyway). @@ -607,6 +722,7 @@ fn xobject( } scope.layer_blocks += 1; out.replaced.push(name); + out.doomed.push(r); Some(None) } Some(b"Image") => { @@ -617,6 +733,7 @@ fn xobject( if scope.covered(b) { scope.report.images_removed += 1; out.replaced.push(name); + out.doomed.push(r); return Some(None); } match image::clear(doc, s, &gs.ctm, scope.rects) { @@ -627,12 +744,14 @@ fn xobject( xobjects.set(n.clone(), Object::Ref(nr)); out.xobjects.push((n.clone(), nr)); out.replaced.push(name); + out.doomed.push(r); scope.report.images_cleared += 1; Some(Some(Op::new("Do", vec![Object::Name(n)]))) } Err(()) => { scope.report.images_removed += 1; out.replaced.push(name); + out.doomed.push(r); Some(None) } } @@ -656,6 +775,7 @@ fn xobject( } scope.report.forms_removed += 1; out.replaced.push(name); + out.doomed.push(r); return Some(None); } let data = match s.decoded() { @@ -663,6 +783,7 @@ fn xobject( Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); + out.doomed.push(r); return Some(None); } Err(_) => return None, @@ -676,6 +797,7 @@ fn xobject( scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.doomed.extend_from_slice(&inner.doomed); out.tag_changes.extend(inner.tag_changes); if scope.mode == Mode::Verify { return None; @@ -711,6 +833,7 @@ fn xobject( xobjects.set(n.clone(), Object::Ref(nr)); out.xobjects.push((n.clone(), nr)); out.replaced.push(name); + out.doomed.push(r); scope.report.forms_rewritten += 1; Some(Some(Op::new("Do", vec![Object::Name(n)]))) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 916d605edb..ddacfe2345 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; @@ -40,7 +42,9 @@ pub enum RedactError { /// A mark's overlay text has a character Helvetica can't draw; nothing was redacted. #[error("{0}")] Undrawable(String), - #[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")] + #[error( + "page {0} has vertical-writing text while a redaction mark is pending, and PdfCraft cannot tell whether that text lies under the mark yet, so nothing was changed" + )] VerticalText(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), @@ -222,12 +226,13 @@ fn overlay_content(marks: &[&Mark]) -> Vec { // The overlay text: its font, colour and alignment; auto-sized to fit unless a size is // set; repeated to fill the area when asked. let look = &m.look; - let (res, width): (&str, fn(&str, f64) -> f64) = match look.font { - pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::helvetica_width), - // Approximations of the standard metrics (no font program is bundled). - pdfcraft_annot::OverlayFont::Times => ("PCTimes", |s, size| pdfcraft_fonts::helvetica_width(s, size) * 0.9), - pdfcraft_annot::OverlayFont::Courier => ("PCCour", |s, size| s.chars().count() as f64 * size * 0.6), + // The published standard-14 metrics, so overlay text is measured as it will be drawn. + let (res, face) = match look.font { + pdfcraft_annot::OverlayFont::Helvetica => ("PCHelv", pdfcraft_fonts::Std14::Helvetica), + pdfcraft_annot::OverlayFont::Times => ("PCTimes", pdfcraft_fonts::Std14::TimesRoman), + pdfcraft_annot::OverlayFont::Courier => ("PCCour", pdfcraft_fonts::Std14::Courier), }; + let width = move |s: &str, size: f64| face.text_width(s, size); let [cr, cg, cb] = look.color.map(|v| v.clamp(0.0, 1.0)); for r in &m.rects { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -271,6 +276,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec { c } +/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them): +/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or +/// a tree deeper than any real one), so callers can stay conservative. +fn resource_source(doc: &Document, page: ObjRef) -> Option> { + let mut seen = HashSet::new(); + let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); + while let Some(r) = parent { + if !seen.insert(r) || seen.len() > 4096 { + return None; + } + let node = doc.get(r); + let d = node.as_dict()?; + if d.contains(b"Resources") { + return Some(Some(r)); + } + parent = d.get(b"Parent").and_then(Object::as_ref); + } + Some(None) +} + +/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the +/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't +/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited +/// dictionary that is direct becomes one shared indirect object, never a copy per page. +fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> { + let pages = pdfcraft_model::pages(doc); + let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources")); + // Ancestor → the reference its resources are shared through, and whether that reference is new. + let mut shared: HashMap = HashMap::new(); + for page in selected.iter().filter_map(|&i| pages.get(i)) { + // What the page tree gives the page; nothing to pin if it gets nothing. + let Some(effective) = page.dict.get(b"Resources").cloned() else { continue }; + if !inherits(doc, page) { + continue; + } + let source = resource_source(doc, page.obj).flatten(); + let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned())); + let value = match source.filter(|_| original.as_ref() == Some(&effective)) { + // `/Parent` agrees with the page tree: share one reference per ancestor. + Some(source) => match shared.get(&source) { + Some((v, _)) => v.clone(), + None => { + // A direct dictionary is made indirect once, so every pinned page shares it. + let (v, new) = match effective { + Object::Ref(_) => (effective, false), + direct => (Object::Ref(doc.add(direct)), true), + }; + shared.insert(source, (v.clone(), new)); + v + } + }, + // A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing. + None => match effective { + Object::Ref(_) => effective, + direct => Object::Ref(doc.add(direct)), + }, + }; + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?; + } + if shared.is_empty() { + return Ok(()); + } + // Ancestors some page still inherits from keep their resources (that page draws them), through + // the shared reference; the others are retired. A page that inherits resources through a chain + // that can't be followed might inherit from any of them, so then none is retired. + let mut still_used: HashSet = HashSet::new(); + for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) { + match resource_source(doc, page.obj) { + Some(Some(source)) => { + if shared.contains_key(&source) { + still_used.insert(source); + } + } + _ => still_used.extend(shared.keys().copied()), + } + } + for (source, (value, new)) in shared { + if still_used.contains(&source) { + if !new { + continue; + } + doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?; + } else { + doc.update_dict(source, |d| { + d.remove(b"Resources"); + })?; + } + } + Ok(()) +} + /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { @@ -292,7 +388,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); + // The original image and form XObjects the rewrite drops or replaces, on any page. + let mut doomed_xobjects: Vec = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); @@ -309,12 +408,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result Re return Ok(0); } let mut total = 0; + // The original image and form XObjects the rewrite drops or replaces, on any page. + let mut doomed = Vec::new(); let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -168,6 +170,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if !write { continue; } + doomed.extend_from_slice(&out.doomed); let mut new_list = list.clone(); let mut changed = false; for (i, new) in out.streams.into_iter().enumerate() { @@ -205,6 +208,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re })?; } } + // What a page no longer draws must not stay reachable through an inherited or shared + // /Resources either. + crate::interp::unlink_doomed(doc, &doomed)?; Ok(total) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 997e6f45a1..7fcbc43057 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -236,7 +236,11 @@ fn vertical_writing_text_under_a_mark_fails_closed() { // Identity-V: apply must refuse rather than report success with the text still in the file. let mut vdoc = make("Identity-V"); mark(&mut vdoc, 0, &[[85.0, 55.5, 105.0, 61.0]], ""); - assert_eq!(apply(&mut vdoc, None), Err(RedactError::VerticalText(1)), "vertical text under a mark must not silently pass"); + let err = apply(&mut vdoc, None).unwrap_err(); + assert_eq!(err, RedactError::VerticalText(1), "vertical text with a pending mark must not silently pass"); + let msg = err.to_string(); + assert!(msg.contains("cannot tell whether"), "{msg}"); + assert!(!msg.contains("text under a redaction mark"), "{msg}"); // Identity-H control: the same glyphs and mark are redacted normally. let mut hdoc = make("Identity-H"); @@ -351,6 +355,120 @@ fn redacted_images_and_forms_do_not_survive_a_full_rewrite_save() { assert_eq!(s.decoded().unwrap(), imkeep, "the unrelated image survives intact"); } +/// Two pages that draw through one resource dictionary: inherited from the /Pages node, or one +/// dictionary object both pages refer to. Page 1 draws a covered image, a partly covered image +/// and a form whose text is under the mark; page 2 draws /ImKeep, and /Im1 too when `again`. +fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { + let res = "<< /Font << /F1 5 0 R >> /XObject << /Im1 7 0 R /ImPart 8 0 R /FmSecret 9 0 R /ImKeep 12 0 R >> >>"; + let (pages_res, page_res) = if inherited { (format!("/Resources {res}"), "") } else { (String::new(), "/Resources 13 0 R") }; + let page2: &[u8] = + if again { b"q 50 0 0 50 20 20 cm /ImKeep Do Q q 50 0 0 50 120 20 cm /Im1 Do Q" } else { b"q 50 0 0 50 20 20 cm /ImKeep Do Q" }; + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 {pages_res} >>").into_bytes(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R {page_res} >>").into_bytes(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q q 50 0 0 100 100 180 cm /ImPart Do Q /FmSecret Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGPARTSECRETPUBPARTBOTTOMROW"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"BT /F1 10 Tf 20 230 Td (REDFORMSECRETTEXT) Tj ET", + ), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 11 0 R {page_res} >>").into_bytes(), + stream("", page2), + stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGKEEPSURVIVE"), + // 13: the dictionary both pages refer to (unused when the resources are inherited). + if inherited { b"<< >>".to_vec() } else { res.as_bytes().to_vec() }, + ]) +} + +/// The samples of the image a page's (possibly inherited) resources name `name`. +fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; + let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; + match &*doc.get(xo.get(name)?.as_ref()?) { + Object::Stream(s) => s.decoded().ok(), + _ => None, + } +} + +/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through +/// before (inherited from the /Pages node, or shared with another page) still named the removed +/// image and form, so a full-rewrite save kept them. +#[test] +fn redacted_xobjects_do_not_survive_through_inherited_or_shared_resources() { + for inherited in [true, false] { + let mut doc = two_pages_one_resource_dictionary(inherited, false); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.images_removed >= 1 && r.forms_rewritten >= 1, "{r:?}"); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + for secret in [&b"REDIMGONECOVERED"[..], b"REDIMGPARTSECRET", b"REDFORMSECRETTEXT"] { + assert!(!has(secret), "{} survived the save (inherited: {inherited})", String::from_utf8_lossy(secret)); + } + // Page 2 still draws its own image through the same dictionary. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(named_image(&saved, 1, b"ImKeep").as_deref(), Some(&b"PUBIMGKEEPSURVIVE"[..]), "inherited: {inherited}"); + } +} + +/// The same image shown on another page is not under a mark there: that page keeps it. +#[test] +fn an_image_another_page_still_draws_through_the_same_resources_is_kept() { + for inherited in [true, false] { + let mut doc = two_pages_one_resource_dictionary(inherited, true); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + apply(&mut doc, None).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 1, b"Im1").as_deref(), Some(&b"REDIMGONECOVERED"[..]), "inherited: {inherited}"); + assert_eq!(named_image(&saved, 0, b"Im1"), None, "the redacted page no longer names it (inherited: {inherited})"); + assert!(content(&saved, 1).contains("/Im1 Do"), "page 2 is untouched"); + } +} + +/// A form without /Resources draws through its caller's: the image it drew under the mark is +/// named by the page's own dictionary, which no `Do` of the page itself ever used. +#[test] +fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { + let mut doc = one_page( + b"/FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); +} + +/// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited +/// /Resources must leave the saved file too. +#[test] +fn a_hidden_layers_image_does_not_survive_through_inherited_resources() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /Resources << /XObject << /ImOC 5 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"q 50 0 0 50 20 20 cm /ImOC Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC 6 0 R", b"HIDDENLAYERIMAGE"), + b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), + ]); + let done = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + assert!(done.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{done:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save"); +} + #[test] fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { // A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA @@ -726,3 +844,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() { assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public")); assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText")); } + +#[test] +fn inherited_resources_do_not_keep_the_original_form() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone(); + doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); +} + +#[test] +fn unselected_pages_keep_inherited_forms() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. +fn inheriting_pages(n: usize) -> (Document, Vec) { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let mut kids = vec![ObjRef::new(3, 0)]; + kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone())))); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), resources); + d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())); + d.set(b"Count".to_vec(), Object::Int(n as i64)); + }) + .unwrap(); + let saved = reopen(&doc); + (saved, kids) +} + +#[test] +fn redacting_one_page_leaves_the_other_pages_untouched() { + let (mut doc, kids) = inheriting_pages(50); + mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], ""); + let before = doc.modified_objects(); + apply(&mut doc, Some(&[0])).unwrap(); + let changed: Vec = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect(); + for kid in &kids[1..] { + assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten"); + } + // The page, its new content and resources, the cleaned form, the parent and one shared + // resources object: not one object per page. + assert!(changed.len() < 10, "{changed:?}"); + // The parent keeps the resources the other pages draw with, through a single shared object. + let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone(); + assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some()); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0); + assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6); +} + +#[test] +fn redacting_every_inheriting_page_retires_the_parent_resources() { + let (mut doc, kids) = inheriting_pages(20); + for page in 0..kids.len() { + mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], ""); + } + apply(&mut doc, None).unwrap(); + assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources")); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page in 0..kids.len() { + assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources")); + assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0); + } +} diff --git a/crates/render/src/lab_bradford_tests.rs b/crates/render/src/lab_bradford_tests.rs new file mode 100644 index 0000000000..7ab1e7ed34 --- /dev/null +++ b/crates/render/src/lab_bradford_tests.rs @@ -0,0 +1,113 @@ +//! Independent flat-patch oracles for the Lab Bradford follow-up to #1237. +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +// These frozen RGB8 values were computed outside the renderer in f64 from the +// CIE Lab inverse, the full XYZ adaptation matrix B^-1 diag(B*D65 / B*white) B, +// and the standard sRGB matrix/transfer function. No production helper is used. +// Bradford reference: https://www.color.org/chadtag/ (ICC TN 02-2003). +// White points: D50 [0.96422, 1, 0.82521], E [1, 1, 1], +// D65 [0.95047, 1, 1.08883]. D65 adaptation is the identity. +const SAMPLES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [40.0, 20.0, -60.0], [70.0, -50.0, 30.0], [100.0, 0.0, 0.0]]; +const D50: [[u8; 3]; 4] = [[119, 119, 119], [67, 86, 194], [66, 194, 113], [255, 255, 255]]; +const E: [[u8; 3]; 4] = [[119, 119, 119], [50, 88, 194], [64, 194, 114], [255, 255, 255]]; +const D65: [[u8; 3]; 4] = [[119, 119, 119], [19, 90, 194], [77, 193, 114], [255, 255, 255]]; + +fn patch(space: &str, operands: &str) -> [u8; 4] { + let content = format!("/C cs {operands} sc 0 0 20 20 re f"); + let bytes = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{space} {operands}: {:?}", page.error); + assert_eq!((page.width, page.height), (20, 20)); + page.rgba.get((10 * 20 + 10) * 4..(10 * 20 + 10) * 4 + 4).unwrap().try_into().unwrap() +} + +fn assert_rgb(actual: [u8; 4], expected: [u8; 3], context: &str) { + assert_eq!(actual[3], 255, "{context}: {actual:?}"); + for (got, want) in actual.into_iter().zip(expected) { + assert!(got.abs_diff(want) <= 1, "{context}: got {actual:?}, expected {expected:?}"); + } +} + +fn check_white(white: &str, expected: [[u8; 3]; 4]) { + let space = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + for (sample, expected) in SAMPLES.into_iter().zip(expected) { + let operands = format!("{} {} {}", sample[0], sample[1], sample[2]); + assert_rgb(patch(&space, &operands), expected, &format!("{white} Lab {sample:?}")); + } +} + +#[test] +fn lab_bradford_d50_neutral_and_chromatic_oracles() { + check_white("[0.96422 1 0.82521]", D50); +} + +#[test] +fn lab_bradford_equal_energy_neutral_and_chromatic_oracles() { + check_white("[1 1 1]", E); +} + +#[test] +fn lab_bradford_d65_identity_oracles() { + check_white("[0.95047 1 1.08883]", D65); +} + +#[test] +fn lab_bradford_indexed_matches_direct_and_oracle() { + for (white, blue) in [("[0.96422 1 0.82521]", D50[1]), ("[1 1 1]", E[1]), ("[0.95047 1 1.08883]", D65[1])] { + // <66 99 33> maps exactly to L*=40, a*=20, b*=-60. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-100 100 -100 100] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <669933>]"), "0"); + assert_rgb(indexed, blue, &format!("{white} Indexed blue")); + assert_eq!(indexed, patch(&lab, "40 20 -60"), "{white} direct/Indexed blue"); + + // A non-default Range maps <80 80 80> to L*=128/255*100, a*=b*=0. + let lab = format!("[/Lab << /WhitePoint {white} /Range [-128 127 -128 127] >>]"); + let indexed = patch(&format!("[/Indexed {lab} 0 <808080>]"), "0"); + assert_rgb(indexed, [119, 119, 119], &format!("{white} Indexed neutral")); + assert_eq!(indexed, patch(&lab, "50.19607843137255 0 0"), "{white} direct/Indexed neutral"); + } +} + +#[test] +fn lab_bradford_degenerate_cones_fall_back_to_unadapted_xyz() { + // In f32, B*[1, 1, 7.1964064] has a zero L cone response. + // B*[3e38, 3e38, 3e38] overflows its L/M responses. The guard must + // return the sample XYZ in both cases. Independent unadapted XYZ -> + // sRGB references for Lab (50, 0, 0) are frozen below. + for (white, expected) in [ + ("[1 1 7.1964064]", [0, 130, 255]), + ( + "[300000000000000000000000000000000000000 300000000000000000000000000000000000000 300000000000000000000000000000000000000]", + [255, 255, 255], + ), + ] { + let space = format!("[/Lab << /WhitePoint {white} >>]"); + assert_rgb(patch(&space, "50 0 0"), expected, &format!("{white} degenerate cones")); + } +} + +#[test] +fn lab_bradford_asymmetric_chromatic_range_matches_direct_and_oracle() { + // With a* [-40, 70] and b* [-90, 20], <80 99 33> maps to + // L*=128/255*100, a*=26, b*=-68. Frozen independent D50 oracle: + // RGB (98, 108, 237). The unequal, asymmetric ranges expose swaps + // and default-Range scaling that a neutral lookup cannot detect. + let lab = "[/Lab << /WhitePoint [0.96422 1 0.82521] /Range [-40 70 -90 20] >>]"; + let indexed = patch(&format!("[/Indexed {lab} 0 <809933>]"), "0"); + assert_rgb(indexed, [98, 108, 237], "D50 asymmetric chromatic Range"); + assert_eq!(indexed, patch(lab, "50.19607843137255 26 -68")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d2ee5712c4..ae146779ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,3 +39,6 @@ pub enum OpenError { #[error("{0}")] Unsupported(String), } + +#[cfg(test)] +mod lab_bradford_tests; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 0e64ac241e..4ad7196629 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3472,6 +3472,78 @@ trailer << /Root 1 0 R >> page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax); } + #[test] + fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { + use hayro_syntax::object::{FromBytes, Object}; + use std::io::Write; + + // Red then green, with only the real sample bits compared: row padding may change. + // The three-column case also checks horizontal differencing: red/green/blue and + // green/red/blue, whose one-bit component differences wrap modulo two. + struct RowCase { + bpc: u8, + columns: u32, + encoded: &'static [u8], + expected: &'static [u8], + masks: &'static [u8], + } + let cases = [ + RowCase { bpc: 1, columns: 1, encoded: &[0x80, 0x40], expected: &[0x80, 0x40], masks: &[0xe0] }, + RowCase { bpc: 2, columns: 1, encoded: &[0xc0, 0x30], expected: &[0xc0, 0x30], masks: &[0xfc] }, + RowCase { bpc: 4, columns: 1, encoded: &[0xf0, 0, 0x0f, 0], expected: &[0xf0, 0, 0x0f, 0], masks: &[0xff, 0xf0] }, + RowCase { bpc: 1, columns: 3, encoded: &[0x99, 0x80, 0x5a, 0x80], expected: &[0x88, 0x80, 0x50, 0x80], masks: &[0xff, 0x80] }, + RowCase { bpc: 8, columns: 1, encoded: &[255, 0, 0, 0, 255, 0], expected: &[255, 0, 0, 0, 255, 0], masks: &[0xff, 0xff, 0xff] }, + ]; + for RowCase { bpc, columns, encoded, expected, masks } in cases { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(encoded).unwrap(); + let compressed = encoder.finish().unwrap(); + let mut bytes = format!( + "<< /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent {bpc} /Columns {columns} >> /Length {} >> stream\n", + compressed.len() + ) + .into_bytes(); + bytes.extend_from_slice(&compressed); + bytes.extend_from_slice(b"\nendstream"); + let Object::Stream(stream) = Object::from_bytes(&bytes).unwrap() else { panic!("stream expected") }; + let decoded = stream.decoded().unwrap(); + assert_eq!(decoded.len(), expected.len()); + for ((actual, expected), mask) in decoded.iter().zip(expected).zip(masks.iter().cycle()) { + assert_eq!(actual & mask, *expected, "{bpc}-bit RGB, {columns} columns"); + } + } + } + + #[test] + fn tiff_predictor_packed_rgb_image_renders_every_row() { + use std::io::Write; + + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&[0x80, 0x40]).unwrap(); + let compressed = encoder.finish().unwrap(); + let content = "q 40 0 0 40 0 0 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n\ +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ +4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ +5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 1 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent 1 /Columns 1 >> /Length {} >> stream\n", + content.len(), + compressed.len() + ) + .into_bytes(); + pdf.extend_from_slice(&compressed); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (40, 40)); + let pixel = |y: usize| &page.rgba[((y * 40 + 20) * 4)..][..4]; + assert_eq!(pixel(10), &[255, 0, 0, 255]); + assert_eq!(pixel(30), &[0, 255, 0, 255]); + } + /// From the nightly `cargo xtask fuzz`: a FlateDecode predictor with `/Columns /// 9223372036854775807` wrapped to a 2^61-byte row allocation (an abort on any machine), and /// a line width of 9223372036854775807 made stroke expansion allocate 10 GB. Vendored hayro @@ -4827,6 +4899,37 @@ trailer << /Root 1 0 R >> assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected); } + /// Issue #1649: Word can save text as a tiny black image whose `/SMask` is a high-resolution + /// bitmap of the glyphs, so the mask alone carries the text. Shrinking that mask by + /// nearest-neighbour keeps one sample in N, and a stroke thinner than N mask pixels vanished: + /// the text came out broken and aliased. Here a one-column stroke of an 8-column mask is drawn + /// 4 pixels wide; nearest reads columns 1, 3, 5 and 7 and misses the stroke in column 2. + #[test] + fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() { + let mask: [u8; 8] = [0, 0, 255, 0, 0, 0, 0, 0]; + let content = "q 4 0 0 1 0 0 cm /Im0 Do Q"; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length 3 >> stream\n00>\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + hex(&mask).len() + 1, + hex(&mask) + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (4, 1)); + let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); + // The stroke covers half of device pixel 1, so it is a mid-tone; the far side stays white. + assert!((64..=191).contains(&grays[1]), "the stroke is lost or not a mid-tone: {grays:?}"); + assert_eq!(grays[3], 255, "{grays:?}"); + } + /// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their /// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels /// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic @@ -5885,4 +5988,46 @@ trailer << /Root 1 0 R >> assert!([(5, 5), (50, 50), (95, 95)].iter().all(|(x, y)| px(&p, *x, *y) == red), "{content}: no stray clip"); } } + + /// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored + /// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap + /// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges + /// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking + /// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels. + #[test] + fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() { + let render = |glyph: &str, mode: u8| { + let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET"); + let pdf = format!( + "%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 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] + /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error); + p.rgba + }; + for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { + let filled = render(glyph, 0); + assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); + for mode in [1, 2, 4, 5, 6, 7] { + assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); + } + assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph"); + } + } } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c8dc3b13bd..c513d5249b 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -149,18 +149,32 @@ impl PageText { /// separated by one space, original case). Used for pattern search (Search & Redact). pub fn find_with(&self, matcher: impl Fn(&[char]) -> Vec>) -> Vec> { let mut chars: Vec = Vec::new(); - let mut owner: Vec = Vec::new(); + // `None` is a word or line separator inserted between glyphs. It is not part of either + // glyph, so a match that starts or ends on it must not take the neighbouring glyph. + let mut owner: Vec> = 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 != ' ') { + let gap = self.space_before.get(i).copied().unwrap_or(false) || self.line_of.get(i) != self.line_of.get(i.saturating_sub(1)); + if i > 0 && gap && chars.last().is_some_and(|c| *c != ' ') { chars.push(' '); - owner.push(i); + owner.push(None); } for c in g.text.chars() { chars.push(if c.is_whitespace() { ' ' } else { c }); - owner.push(i); + owner.push(Some(i)); } } - matcher(&chars).into_iter().filter(|r| r.start < r.end && r.end <= chars.len()).map(|r| owner[r.start]..owner[r.end - 1] + 1).collect() + matcher(&chars) + .into_iter() + .filter_map(|r| { + if r.start >= r.end || r.end > chars.len() { + return None; + } + let glyphs = owner.get(r.start..r.end)?; + let start = glyphs.iter().find_map(|g| *g)?; + let end = glyphs.iter().rev().find_map(|g| *g)?.checked_add(1)?; + (start < end).then_some(start..end) + }) + .collect() } /// Index of the glyph nearest to a view-space point (for selection anchors). @@ -1276,14 +1290,18 @@ pub fn layout(glyphs: Vec) -> PageText { /// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { - // Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. + // Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. A redraw + // also overlaps most of its own width: a narrow letter in condensed text (80 Tz and below) sits + // closer than a fifth of its height to its double, as the second "l" of "Alle" (#1467). 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); + let w = g.rect[2] - g.rect[0]; + let dx = if w > 0.05 * h { (h * 0.2).min(w * 0.5) } else { h * 0.2 }; 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 { + if keep[j] && p.text == g.text && (p.rect[0] - g.rect[0]).abs() < dx && (p.rect[1] - g.rect[1]).abs() < h * 0.2 { keep[i] = false; break; } @@ -1853,6 +1871,36 @@ mod tests { assert_eq!(t.nearest(52.0, 14.0), Some(5)); } + /// #1315: a custom match that ends on a reconstructed word or line separator does not take + /// the following glyph. + #[test] + fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { + let glyphs: Vec = "quickbrown" + .chars() + .enumerate() + .map(|(i, c)| { + let x = i as f32 * 6.0; + g(&c.to_string(), x, 0.0, x + 6.0) + }) + .collect(); + for line_break in [false, true] { + let mut space_before = vec![false; 10]; + space_before[5] = !line_break; + let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); + let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let plain = if line_break { "quick\nbrown" } else { "quick brown" }; + assert_eq!(page.plain_text(), plain); + assert_eq!(page.find("quick "), vec![0..5]); + let span = |r: std::ops::Range| Vec::from([r]); + assert_eq!(page.find_with(|_| span(0..5)), vec![0..5]); + assert_eq!(page.find_with(|_| span(5..11)), vec![5..10]); + assert_eq!(page.find_with(|_| span(6..11)), vec![5..10]); + let hits = page.find_with(|_| span(0..6)); + assert_eq!(page.text_of(hits[0].clone()), "quick"); + assert_eq!(hits, vec![0..5]); + } + } + #[test] fn phrase_search_ignores_repeated_spaces() { // "quick brown" drawn with two space glyphs in a row. Text extraction keeps both spaces, @@ -1900,6 +1948,29 @@ mod tests { v.push(g(&c.to_string(), x + 0.3, 10.0, x + 6.3)); } assert_eq!(layout(v).plain_text(), "Bold"); + // A zero-width combining mark redrawn on itself is dropped too. + let v = vec![g("e", 10.0, 10.0, 16.0), g("\u{301}", 13.0, 10.0, 13.0), g("e", 10.0, 10.0, 16.0), g("\u{301}", 13.0, 10.0, 13.0)]; + assert_eq!(layout(v).plain_text(), "e\u{301}"); + } + + /// #1467: condensed text (80 Tz and below, OCR layers fitted to the scan) sets narrow letters + /// closer than a fifth of their height apart; a doubled letter there is two letters, not a redraw. + #[test] + fn condensed_double_letters_are_kept() { + for scale in [0.8, 0.65, 0.5] { + let mut v = Vec::new(); + let mut x = 10.0; + // Helvetica advances at 10 pt, in thousandths of an em. + for (c, adv) in [('A', 667.0), ('l', 222.0), ('l', 222.0), ('e', 556.0)] { + let w = adv / 100.0 * scale; + v.push(g(&c.to_string(), x, 10.0, x + w)); + x += w; + } + assert_eq!(layout(v.clone()).plain_text(), "Alle", "at {scale} horizontal scaling"); + // Each letter redrawn a fifth of a point to the right is still one letter. + let bold: Vec = v.iter().flat_map(|c| [c.clone(), g(&c.text, c.rect[0] + 0.2, 10.0, c.rect[2] + 0.2)]).collect(); + assert_eq!(layout(bold).plain_text(), "Alle", "fake bold at {scale} horizontal scaling"); + } } #[test] @@ -2463,4 +2534,43 @@ mod tests { let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]); assert_eq!(t.column_text(&all), "日本\n語文"); } + + /// An embedded TrueType font with no `/Encoding`, flagged `flags`, its `/ToUnicode` (if + /// any) mapping only printable ASCII, as OCR engines write the text layer of a scan. + fn ocr_layer_text(flags: i64, ascii_to_unicode: bool) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec())); + let descriptor = + doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => flags, "FontFile2" => program }); + let mut font = dictionary! { "Type" => "Font", "Subtype" => "TrueType", "BaseFont" => "Inter-Regular", "FontDescriptor" => descriptor }; + if ascii_to_unicode { + let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Ascii def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 1 beginbfrange <20> <7E> <0020> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec()))); + } + let font = doc.add_object(font); + // WinAnsi bytes: "Rückfragen für Überhitzungsschutz: können, weiß, Café". + let mut ops = b"BT /F1 14 Tf 1 0 0 1 20 200 Tm (".to_vec(); + ops.extend_from_slice(b"R\xfcckfragen f\xfcr \xdcberhitzungsschutz: k\xf6nnen, wei\xdf, Caf\xe9"); + ops.extend_from_slice(b") Tj ET"); + let content = doc.add_object(Stream::new(dictionary! {}, ops)); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() + } + + #[test] + fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() { + // Café: 0xE9 is /Oslash in StandardEncoding, so reading the codes as Standard first and + // WinAnsi only for the gaps would still give "CafØ". + let expected = "Rückfragen für Überhitzungsschutz: können, weiß, Café"; + assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode"); + assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only"); + } } diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index cf608c1f95..012cd43a61 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1220,6 +1220,18 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod _ => false, } }; + // A later cross-reference section may carry an entry this reader sets aside (an in-use entry + // at offset 0, a second entry for one object, a reserved entry type). The renderer may follow + // it and draw another object than the one compared below, so the page could show new content + // while every comparison here finds the signed object untouched. No writer has a reason to + // add such an entry after signing: it is never a permitted change. + for (_, num) in doc.xref_oddities().iter().filter(|o| !old.xref_oddities().contains(o)) { + let r = ObjRef { num: *num, generation: doc.generation(*num) }; + let what = if old_content.contains(&r) || signed_pages.contains(&r) { kind::PAGE_CONTENT } else { kind::OTHER }; + if !disallowed.contains(&what) { + disallowed.push(what); + } + } for num in doc.object_numbers() { if same_place(num) { continue; @@ -1228,6 +1240,12 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod let new = doc.get(r); let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)); if before.as_deref() == Some(&*new) { + // The same content at a new place. That is true for the renderer as well only when + // the entry locates the object itself: where this reader had to search for the + // object, another reader may arrive at a different one. + if !doc.is_edited(num) && !doc.xref_entry_is_exact(num) && !disallowed.contains(&kind::OTHER) { + disallowed.push(kind::OTHER); + } continue; } let what = match &*new { diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index d4cd2bae33..609926f3bb 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -1900,3 +1900,81 @@ fn details_say_themselves() { assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern); } } + +/// `signed` with one more revision appended by hand: a new definition of object 4 (the page's +/// content stream) that draws `text`, then a cross-reference table section whose subsections are +/// what `xref` returns for the offset of that definition. The section continues the signed chain. +fn append_content_revision(signed: &[u8], text: &str, xref: impl Fn(usize) -> String) -> Vec { + let at = signed.windows(9).rposition(|w| w == b"startxref").unwrap() + 9; + let prev: String = signed[at..].iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect(); + let mut out = signed.to_vec(); + let content = format!("BT /F1 14 Tf 20 250 Td ({text}) Tj ET"); + let object = out.len(); + out.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len()).as_bytes()); + let section = out.len(); + out.extend_from_slice( + format!("xref\n{}trailer\n<< /Size 60 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n", xref(object)).as_bytes(), + ); + out +} + +fn xref_entry(offset: usize) -> String { + format!("{offset:010} 00000 n \n") +} + +/// The reader that judges changes and the renderer that draws the page resolve some +/// cross-reference entries differently. A revision appended after signing can redefine the page's +/// content and give the object an entry the reader sets aside (an in-use entry at offset 0, or +/// a second entry after one that still names the signed object): the reader then keeps comparing +/// the signed object with itself while the page shows the new one. Such an entry is never a +/// permitted change. +#[test] +fn a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for certify in LEVELS { + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap(); + // Where the signed revision keeps the page's content stream. + let signed_at = signed.windows(8).position(|w| w == b"\n4 0 obj").unwrap() + 1; + // Each writes the section's subsections, given where the new object 4 was put. + type Subsections = Box String>; + let attacks: [(&str, Subsections); 3] = [ + ("an in-use entry at offset 0", Box::new(|_| format!("4 1\n{}", xref_entry(0)))), + ("a second entry for the object", Box::new(move |new| format!("4 1\n{}4 1\n{}", xref_entry(signed_at), xref_entry(new)))), + // The plain case, which was always caught: one entry naming the new object. + ("an ordinary entry", Box::new(|new| format!("4 1\n{}", xref_entry(new)))), + ]; + for (name, xref) in attacks { + let edited = append_content_revision(&signed, "Forged text", xref); + let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid, "{certify:?}, {name}: {:?}", s.details); + assert!( + matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")), + "{certify:?}, {name}: {:?}", + s.modification + ); + } + } +} + +/// An entry that does not point at the object it names leaves the reader to find the object by +/// searching the file. What it finds may equal the signed object, yet another reader may search +/// differently: the object cannot be called unchanged. +#[test] +fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + // A copy of the signed content, named by an entry two bytes into its `4 0 obj` header. + let edited = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new + 2))); + let doc = open(&edited); + assert!(!doc.xref_entry_is_exact(4) && doc.xref_entry_is_exact(3)); + let s = signatures(&doc, &edited, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification); + + // The same copy under an entry that does point at it is the unchanged object it looks like. + let honest = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new))); + let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8ee4e68efb..5400829cde 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -48,6 +48,9 @@ skrifa = "0.44" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] } +# A plain fsync where a volume refuses the full flush `sync_all` asks for (SMB, FUSE); already in +# the tree through pdfcraft-cli. +rustix = { version = "1", features = ["fs"] } [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index 0fc8e2b21a..67683beb58 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -357,14 +357,18 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let o = &self.a11y_options; - let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (o.from.max(1) - 1..o.to.max(o.from)).collect()) }; + // The range was picked on whatever document had the options dialog last; with the dialog + // turned off it reaches here unchecked, and a page past the end would select nothing. + let to = o.to.clamp(1, doc.info.pages.len().max(1)); + let from = o.from.clamp(1, to); + let options = Options { rules: o.rules.clone(), pages: (!o.all_pages).then(|| (from - 1..to).collect()) }; let Some(report) = doc.accessibility_check(&options) else { self.notify_tr("This document can't be checked: it couldn't be read for editing"); return; }; // Rules skipped by hand stay skipped when checking again. let mut report = report; - for r in report.results.iter_mut().filter(|r| self.a11y_skipped.contains(&r.rule)) { + for r in report.results.iter_mut().filter(|r| self.a11y_skipped.contains(&(id, r.rule))) { r.status = Status::Skipped; r.findings.clear(); } @@ -448,12 +452,12 @@ impl PdfCraftApp { } } PanelAction::Skip(rule) => { - self.a11y_skipped.insert(rule); - if let Some((_, r)) = self.a11y.report.as_mut() - && let Some(x) = r.results.iter_mut().find(|x| x.rule == rule) - { - x.status = Status::Skipped; - x.findings.clear(); + if let Some((id, r)) = self.a11y.report.as_mut() { + self.a11y_skipped.insert((*id, rule)); + if let Some(x) = r.results.iter_mut().find(|x| x.rule == rule) { + x.status = Status::Skipped; + x.findings.clear(); + } } } PanelAction::Fix(rule) => { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e213..9dc2c98dfa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -354,6 +354,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The drag in progress started a text selection: the wheel scrolls the page during it + /// (#721), which egui's scroll area refuses while anything is dragged. + selection_drag: bool, /// Clicks in the current run on the text layer: two select a word, three the line, four the page. clicks: u32, last_press_time: f64, @@ -394,6 +397,7 @@ pub struct DocView { pub(crate) edit_lines: HashMap)>, pub line_editor: Option, pub(crate) text_background: crate::edit_text_ui::TextBackground, + pub(crate) move_background: crate::content_ui::MoveBackground, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, @@ -612,6 +616,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + selection_drag: false, clicks: 0, last_press_time: 0.0, last_press_pos: None, @@ -635,6 +640,7 @@ impl DocView { edit_lines: HashMap::new(), line_editor: None, text_background: Default::default(), + move_background: Default::default(), edit_images: HashMap::new(), objects: Default::default(), image_selection: None, @@ -780,7 +786,7 @@ impl DocView { } pub fn render_pending(&self) -> bool { - !self.last_queue.is_empty() || self.text_background.pending() + !self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending() } /// Re-render every page and text layer (the document's appearance changed, e.g. a layer @@ -1343,6 +1349,7 @@ impl DocView { // Signature previews own a separate renderer and image/background textures. self.signature_drag = Default::default(); self.text_background = Default::default(); + self.move_background = Default::default(); if !self.last_queue.is_empty() { pool.set_queue(Vec::new()); self.last_queue.clear(); @@ -1614,6 +1621,7 @@ impl DocView { let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.pages.insert(page, PageTex { tag: r.request.tag, tex }); self.signature_drag.page_received(page); + self.move_background.page_received(page); self.waiting_since.remove(&page); if !self.stale_tiles.iter().any(|k| k.0 == page) { self.forms.unbaked.retain(|(_, p)| *p != page); @@ -1753,6 +1761,17 @@ impl DocView { } } +/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) +/// show none: they are page content being edited, not comments. +fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { + if a.in_reply_to.is_some() || a.fill_sign { + return None; + } + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + let body = a.contents.clone().unwrap_or_default(); + Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) +} + /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 @@ -2324,6 +2343,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { || app.palette_open || app.password_prompt.is_some() || app.pending_link.is_some() + || app.pending_remote_link.is_some() || app.updates.open; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; @@ -2347,6 +2367,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.content.selected = None; } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); // A destination's position (bookmarks, links) refines `goto`: its point at the window's // top-left, or the centre of a /FitR rectangle at the window's centre. @@ -2377,6 +2398,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } // The Hand tool pans: the content widget takes every drag, so scroll by its delta. if hand { if resp.dragged() { @@ -2546,6 +2571,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); + if editing_content { + let page_tex = view.pages.get(&i).map(|p| p.tex.id()); + view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. @@ -2789,6 +2818,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { @@ -2942,12 +2972,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // freehand, where it would cover the next stroke (#429). let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); let gesturing = view.comments.gesture.is_some() || freehand; - for a in info.annotations.iter().filter(|a| a.page == i && a.in_reply_to.is_none() && !gesturing) { + for a in info.annotations.iter().filter(|a| a.page == i && !gesturing) { 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(); - hover_text = Some((p, if body.is_empty() { who } else { format!("{who}\n{body}") })); + if sr.contains(p) + && hover_text.is_none() + && let Some(text) = annotation_hover_text(a) + { + hover_text = Some((p, text)); } } } @@ -2979,6 +3010,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. A Line/Page event clears precise_scroll_until; a Point + // gesture's eased tail after its suppression deadline is not picked up. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && view.precise_scroll_until == 0.0 + && ui.rect_contains_pointer(avail) + { + let delta = ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)); + if delta != Vec2::ZERO { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } // The page navigated to stays current while no page shows more of itself, so the next // page step starts from it rather than from a page further down the screen (#188). // It also stays current while it is wholly on screen: a short page (a cheque) gone to @@ -4498,6 +4546,32 @@ pub(crate) fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) - mod tests { use super::*; + fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation { + pdfcraft_render::Annotation { + page: 0, + subtype: "FreeText".into(), + author: None, + contents: Some("Hello".into()), + modified: None, + name: None, + in_reply_to: None, + rect: [0.0; 4], + color: None, + index: 0, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign, + } + } + + #[test] + fn hover_popup_skips_fill_and_sign_marks() { + assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello")); + assert_eq!(annotation_hover_text(&annotation(true)), None); + } + #[test] fn rendered_rasters_become_texture_images_without_a_copy() { let pdf = b"%PDF-1.4 diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1f2d2371f5..780187816e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -4,7 +4,7 @@ use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, ve use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; -use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets}; +use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets, window_controls}; /// Height of the tab strip when it doubles as the macOS title bar (#789). AppKit draws the traffic /// lights itself, centred in the standard 28 pt title-bar band at the top of the window, and moving @@ -46,14 +46,31 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } + // GNOME on Wayland: the strip is the header bar, with the window buttons at its ends. + let fullscreen = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false)); + let controls = app + .window_controls + .clone() + .filter(|_| app.draws_window_frame() && !fullscreen) + .unwrap_or_else(|| window_controls::Layout { left: Vec::new(), right: Vec::new() }); ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; + if !controls.left.is_empty() { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + window_controls::buttons(ui, &controls.left); + }); + ui.add_space(window_controls::SIDE_GAP - 4.0); + } if icons::button(ui, "house", button, app.active.is_none() && !app.combine_showing(), tl!("Home")).clicked() { app.active = None; app.combine_tab.focused = false; } // Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs. - let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 2.0 * button + 50.0; + let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + + 2.0 * button + + 50.0 + + window_controls::width(&controls.right); // Open stays in sight after the tabs: its icon, text and padding, and the gaps around it. let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0; let mut tabs_width = (ui.available_width() - controls_width - open_width).max(0.0); @@ -194,6 +211,16 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.open_dialog(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if !controls.right.is_empty() { + // Laid out left to right inside the right-to-left run, so they read in + // the desktop's order. + let size = egui::vec2(window_controls::width(&controls.right) - window_controls::SIDE_GAP, window_controls::BUTTON); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + window_controls::buttons(ui, &controls.right); + }); + ui.add_space(window_controls::SIDE_GAP - 4.0); + } let (icon, label) = match app.theme_preference { ThemePreference::System => ("monitor", tl!("Use system setting")), ThemePreference::Light => ("sun", tl!("Light gray")), diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 2d2bb173aa..467a040b97 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -380,6 +380,8 @@ pub(crate) struct Incoming { pub modified: Option, /// Something to tell the user about where it came from. pub note: Option, + /// The open document it is, when it came from a tab. + pub open_doc: Option, } /// A new value for [`CombineFile::auth`]: counted for the whole run, outside the undo history. @@ -400,6 +402,9 @@ pub struct CombineFile { pub(crate) group: u64, pub name: String, pub bytes: Arc>, + /// The file as it was added: `bytes` itself for a PDF, the image or text it was made from + /// otherwise (converted anew, with its own date, each time it is added). + source: Arc>, pub pages: usize, /// Each page's displayed size in points; shared, so undo snapshots don't copy it. pub(crate) sizes: Arc<[(f32, f32)]>, @@ -420,6 +425,8 @@ pub struct CombineFile { pub notes: Vec, /// Where it came from, shown among the warnings (e.g. an open document's unsaved changes). origin_note: Option, + /// The open document it is, when it came from a tab: Add open documents leaves it out. + open_doc: Option, /// The range last checked and the result: the number of pages it takes and the first of /// them (0-based), or why it is wrong. checked: Option<(String, RangeCheck)>, @@ -436,7 +443,7 @@ impl CombineTab { // unrelated files, and never count a source split into parts as several additions. let mut candidates: HashMap<(&str, usize), Vec> = HashMap::new(); for (i, f) in files.iter().enumerate() { - candidates.entry((&f.name, f.bytes.len())).or_default().push(i); + candidates.entry((&f.name, f.source.len())).or_default().push(i); } let mut twice = vec![false; files.len()]; for indices in candidates.values() { @@ -448,7 +455,7 @@ impl CombineTab { let mut contents: HashMap<&[u8], Vec> = HashMap::new(); for &i in indices { if let Some(f) = files.get(i) { - contents.entry(f.bytes.as_slice()).or_default().push(i); + contents.entry(f.source.as_slice()).or_default().push(i); } } for copies in contents.values() { @@ -2381,14 +2388,15 @@ impl PdfCraftApp { /// Add picked files to the Combine files list (and show it). pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec)>) { - let incoming = files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None }).collect(); + let incoming = + files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None }).collect(); self.stage_combine_with(incoming); } /// Add files to the Combine files list, with what can be known about them up front. pub(crate) fn stage_combine_with(&mut self, files: Vec) { let mut added = Vec::new(); - for Incoming { name, bytes, modified, note } in files { + for Incoming { name, bytes, modified, note, open_doc } in files { // Images and text files become PDFs as they join the list (#569): from then on they // are ordinary files, with pages, thumbnails and page ranges. // A file that can't be read joins the list all the same, with why (the Warnings @@ -2400,6 +2408,7 @@ impl PdfCraftApp { lock: None, notes: Vec::new(), }; + let source = bytes.clone(); let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) { Ok(pdf) => { let assessed = assess(&pdf, None); @@ -2428,6 +2437,7 @@ impl PdfCraftApp { group: 0, name, bytes, + source, pages: 0, range: String::new(), modified, @@ -2438,6 +2448,7 @@ impl PdfCraftApp { auth: 0, notes: Vec::new(), origin_note: note, + open_doc, checked: None, }; f.take(assessed, None); @@ -2536,18 +2547,22 @@ impl PdfCraftApp { } } - /// Add documents open in tabs (by tab index), as they are now. + /// Add documents open in tabs (by tab index), as they are now; those already in the list + /// stay out (#1356). The same document, not the same name: two files called alike are two. pub(crate) fn add_open_documents(&mut self, tabs: &[usize]) { let mut incoming = Vec::new(); for &i in tabs { let Some(doc) = self.views.get(i).and_then(|v| self.session.get(v.id)) else { continue }; + if self.combine_draft.iter().any(|f| f.open_doc == Some(doc.id)) { + continue; + } #[cfg(not(target_arch = "wasm32"))] let modified = if doc.dirty { None } else { doc.path.as_ref().and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) }; #[cfg(target_arch = "wasm32")] let modified = None; let note = doc.dirty.then(|| tl!("Includes unsaved changes").to_string()); let name = if doc.name.to_ascii_lowercase().ends_with(".pdf") { doc.name.clone() } else { format!("{}.pdf", doc.name) }; - incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note }); + incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note, open_doc: Some(doc.id) }); } self.stage_combine_with(incoming); } @@ -2636,6 +2651,28 @@ mod tests { assert!(app.combine_tab.duplicate_warnings(&app.combine_draft).is_empty()); } + /// #1303: an image or text file is converted anew, with its own date, each time it is added, + /// so it is the file as added that is compared, not the PDF made from it. + #[test] + fn the_same_image_or_text_file_added_twice_is_a_duplicate() { + static NOW: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1_700_000_000); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(12, 4, image::Rgba([20, 30, 40, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let png = png.into_inner(); + let warnings = |first: (&str, &[u8]), second: (&str, &[u8])| { + let mut app = PdfCraftApp::new(); + app.session = std::mem::take(&mut app.session).with_clock(|| NOW.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + for (name, bytes) in [first, second] { + app.stage_combine(vec![(name.into(), bytes.to_vec())]); + } + assert_ne!(app.combine_draft[0].bytes, app.combine_draft[1].bytes, "each addition is converted with its own date"); + app.combine_tab.duplicate_warnings(&app.combine_draft).to_vec() + }; + assert_eq!(warnings(("notes.txt", b"Hello"), ("notes.txt", b"Hello")), [true, true], "the same text file"); + assert_eq!(warnings(("photo.png", &png), ("photo.png", &png)), [true, true], "the same image"); + assert_eq!(warnings(("notes.txt", b"Hello"), ("notes.txt", b"World")), [false, false], "different text, same name and size"); + } + #[test] fn every_read_of_a_file_is_new_even_after_undo() { // Regression: the read counter lived in the undo history, so unlock → undo → unlock (with diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 2266cb309e..7d7b6b9c58 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -393,6 +393,11 @@ impl PdfCraftApp { } } "a11y.report" => self.show_accessibility_report(), + "a11y.autotag" => { + if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { + self.choose_right_panel(Some(RightPanel::Tags)); + } + } "a11y.alt_text" => self.start_alt_text(), "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index e6ac0ba167..48d3571e13 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -90,8 +90,8 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return }; - let Some(tool) = crate::comments::tool_for(a) else { return }; let Some(p) = doc.comment_props(page, index) else { return }; + let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { style.color = c; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 623f89b62e..774e37b30f 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -315,6 +315,21 @@ pub(crate) fn made_by(a: &Annotation, tool: CommentTool) -> bool { } } +/// The tool whose defaults "Make Current Properties Default" should update. +/// +/// Arrows are stored as Line annotations. An arrow ending belongs to the Arrow tool, so its +/// defaults stay separate from a plain line. +pub(crate) fn tool_for_default(a: &Annotation, endings: Option<&[LineEnding]>) -> Option { + match tool_for(a) { + Some(CommentTool::Line) if endings.is_some_and(|ends| ends.iter().copied().any(arrow_ending)) => Some(CommentTool::Arrow), + other => other, + } +} + +fn arrow_ending(ending: LineEnding) -> bool { + matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { @@ -659,6 +674,18 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); } } + // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats + // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a + // single PDF point is visible and remains editable like any other drawing. + if tool == CommentTool::Ink + && resp.clicked() + && over_page + && let Some(p) = pointer + && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) + { + cv.selected = None; + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } true } QuickTool::Comment(tool) if tool.clicks_points() => { @@ -931,7 +958,18 @@ fn select_input( // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { + let follow_link = cx.tool == QuickTool::Select + && !consumed + && cv.gesture.is_none() + && !resp.double_clicked() + && pointer + .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))); match pointer.and_then(|p| cx.hit(p)) { + Some(a) if a.subtype == "Highlight" && follow_link => { + // A plain click follows the link beneath text highlighting. Keep other + // annotations and active gestures in control of their own input. + cv.selected = None; + } Some(a) => { cv.selected = Some((cx.page, a.index)); cv.reveal = true; @@ -1083,7 +1121,7 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])]; let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0; match tool { - CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }), + CommentTool::Ink if !points.is_empty() => Some(Shape::Ink { strokes: vec![points.to_vec()] }), CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, @@ -1852,7 +1890,7 @@ mod tests { fn drawn_shapes_ignore_slips() { assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None); assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] })); - assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None); + assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), Some(Shape::Ink { strokes: vec![vec![[0.0, 0.0]]] })); assert!(matches!( drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. }) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5553c8501e..39355e6913 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -105,6 +105,128 @@ impl ContentView { } } +/// While an added item is moved, the page without it, rendered once per gesture: the item's own +/// pixels follow the pointer over it instead of staying put until release (#1488). +#[derive(Default)] +pub(crate) struct MoveBackground { + key: Option<(u64, usize, usize)>, + renderer: Option, + texture: Option, + tag: Option, + /// The item's display-space box when the gesture started. + from: Option<[f64; 4]>, + /// The applied move's box. The page raster on screen still has the item where it was until + /// its replacement arrives, so the preview stays up until then. + settling: Option<[f64; 4]>, +} + +impl MoveBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { + let key = match (cv.grab, cv.selected) { + (Some((gp, Grab::Move, _)), Some((sp, si))) if gp == sp => Some((doc.edit_generation(), sp, si)), + _ => None, + }; + if !(key.is_none() && self.settling.is_some()) && self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, index)) = key { + self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.from = on_page(&doc.added, page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); + } + } + if self.settling.is_some() { + return; + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // Without a background the item stays put while dragged, as before. + self.renderer = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "added-content-move-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The move was applied: keep the preview, now at the new generation, until the page is + /// rendered again. Any other edit ends it. + pub fn committed(&mut self, edit: &Edit, generation: u64) { + match edit { + Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + self.key = Some((generation, *page, *index)); + self.settling = Some(content.rect()); + } + _ => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.is_some() && self.key.is_some_and(|(_, p, _)| p == page) { + *self = Self::default(); + } + } + + /// The page without the moving item, then the item's pixels taken from `page_tex` (the page + /// as rendered, item included where it was) at the dragged or applied position. + #[allow(clippy::too_many_arguments)] + pub fn paint( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + cv: &ContentView, + page_tex: Option, + ) { + let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + if kp != page { + return; + } + let r = screen_rect(xf, info, page, from); + let d = if let Some((_, _, start)) = cv.grab { + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + p - start + } else if let Some(to) = self.settling { + // A moved text box keeps its top edge (its height follows its lines). + screen_rect(xf, info, page, to).left_top() - r.left_top() + } else { + return; + }; + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); + let r = r.expand(1.0); + let mut mesh = egui::Mesh::with_texture(page_tex); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + let (u, v) = xf.screen_to_norm(c); + mesh.vertices.push(egui::epaint::Vertex { pos: c + d, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } +} + /// Text style for new text (the panel's Format controls set it). pub fn default_style() -> AddedText { AddedText { size: 12.0, ..AddedText::default() } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65a12e1997..eaa102b56e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -36,7 +36,8 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. -//! - `ui.open {path}`: open a file. +//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they +//! complete and are listed in `ui.state.pending_opens`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -396,11 +397,16 @@ impl Control { fn drag(&mut self, p: &Value) -> Result { let point = |k: &str| -> Result { match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::>()).as_deref() { - Some([x, y]) => Ok(egui::pos2(*x as f32, *y as f32)), + Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32)) + .filter(|pos| pos.is_finite()) + .ok_or_else(|| format!("ui.drag: {k} must be [x, y], finite coordinates in points")), _ => Err(format!("ui.drag: {k} must be [x, y] in points")), } }; let (from, to) = (point("from")?, point("to")?); + if !(to - from).is_finite() { + return Err("ui.drag: from and to must be a finite distance apart, in points".into()); + } let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize; let modifiers = modifiers(p.get("modifiers"))?; let which = pointer_button(p, "ui.drag")?; @@ -416,7 +422,10 @@ impl Control { fn click(&mut self, p: &Value) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - let pos = egui::pos2(x as f32, y as f32); + let point = |key: &str, n: f64| { + Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points")) + }; + let pos = egui::pos2(point("x", x)?, point("y", y)?); let which = pointer_button(p, "ui.click")?; let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE }; let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true)], vec![button(false)]]); @@ -642,9 +651,15 @@ impl Host for crate::PdfCraftApp { Some(json!({ "tab": i, "doc": v.id.0, "name": d.name, "path": d.path, "pages": d.info.pages.len(), "dirty": d.dirty, "active": self.active == Some(i) })) }) .collect(); + #[cfg(not(target_arch = "wasm32"))] + let pending_opens: Vec = + self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect(); + #[cfg(target_arch = "wasm32")] + let pending_opens: Vec = Vec::new(); json!({ "docking": self.docking, "documents": docs, + "pending_opens": pending_opens, "active": active.map(|v| json!({ "doc": v.id.0, "page": v.current + 1, @@ -743,6 +758,8 @@ impl Host for crate::PdfCraftApp { self.open_path(path); if self.views.len() > before || self.password_prompt.is_some() { Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })) + } else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) { + Ok(json!({ "accepted": path, "pending": true })) } else { Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))) } @@ -766,10 +783,11 @@ pub struct Endpoint { /// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`. /// /// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be -/// `{"method": "auth", "params": {"token": …}}`; anything else closes the connection. +/// `{"method": "auth", "params": {"token": …}}`; anything else closes the connection, and so does +/// a line longer than [`MAX_AUTH_LINE`] before that. #[cfg(not(target_arch = "wasm32"))] pub fn serve(client: ControlClient) -> std::io::Result { - use std::io::{BufRead, BufReader, Write}; + use std::io::{BufReader, Write}; let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; let port = listener.local_addr()?.port(); let token = random_token()?; @@ -782,12 +800,28 @@ pub fn serve(client: ControlClient) -> std::io::Result { let Ok(read) = stream.try_clone() else { return }; let mut write = stream; let mut authed = false; - for line in BufReader::new(read).lines() { - let Ok(line) = line else { break }; + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + // Until `auth` succeeds, lines are capped at MAX_AUTH_LINE; afterwards they + // are read whole, as before. + let max = if authed { usize::MAX } else { MAX_AUTH_LINE }; + match read_line_limited(&mut reader, &mut buf, max) { + Ok(LineRead::Line) => {} + // End of stream or a failed read ends the connection, as before. + Ok(LineRead::End) | Err(_) => break, + Ok(LineRead::TooLong) => { + let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth")); + let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + break; + } + } + // Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did. + let Ok(line) = std::str::from_utf8(&buf) else { break }; if line.trim().is_empty() { continue; } - let (reply, close) = match serde_json::from_str::(&line) { + let (reply, close) = match serde_json::from_str::(line) { // Before `auth`, like any other first request, this closes the connection. Err(e) => (rpc_error(Value::Null, -32700, &format!("parse error: {e}")), !authed), Ok(msg) => { @@ -823,6 +857,44 @@ pub fn serve(client: ControlClient) -> std::io::Result { Ok(Endpoint { port, token }) } +/// Longest line read before a connection has authenticated. The only acceptable request then is +/// `auth`, about a hundred bytes; a longer line is refused without buffering the rest of it. +#[cfg(not(target_arch = "wasm32"))] +const MAX_AUTH_LINE: usize = 4 * 1024; + +/// What [`read_line_limited`] found. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, PartialEq, Eq)] +enum LineRead { + /// A line, now in the buffer (newline and a trailing `\r` stripped). + Line, + /// End of stream. + End, + /// The line is longer than the limit; at most limit + 1 bytes of it were read. + TooLong, +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf`. +#[cfg(not(target_arch = "wasm32"))] +fn read_line_limited(reader: &mut impl std::io::BufRead, buf: &mut Vec, max: usize) -> std::io::Result { + use std::io::{BufRead, Read}; + buf.clear(); + // `take` bounds what is buffered for one line: never more than `max` + 1 bytes. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + if reader.take(limit).read_until(b'\n', buf)? == 0 { + return Ok(LineRead::End); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(LineRead::Line); + } + // No newline: either the limit was passed, or this is a last line before end of stream. + Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line }) +} + #[cfg(not(target_arch = "wasm32"))] fn rpc_error(id: Value, code: i64, message: &str) -> Value { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) @@ -867,4 +939,75 @@ mod transport_tests { }; assert!(closed, "the connection stayed open: {next:?}"); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn lines_are_read_up_to_the_limit() { + use super::{LineRead, read_line_limited}; + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End, "then end of stream"); + let long = [b'x'; 100]; + let mut input: &[u8] = &long; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::TooLong); + assert_eq!(input.len(), 100 - 11, "at most limit + 1 bytes are read; the rest is left"); + } + + /// Before authenticating, a line longer than [`super::MAX_AUTH_LINE`] is refused as soon as + /// the limit is passed (no newline needed) and the connection is closed. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn an_overlong_line_before_auth_is_refused() { + use std::io::{BufRead, BufReader, ErrorKind, Write}; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve(client).unwrap(); + let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); + s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + // Exactly one byte over the limit, without a newline: the server reads it all, so the + // close below is a clean end of stream. + s.try_clone().unwrap().write_all(&vec![b'x'; super::MAX_AUTH_LINE + 1]).unwrap(); + let mut r = BufReader::new(s); + let mut first = String::new(); + r.read_line(&mut first).unwrap(); + let reply: serde_json::Value = serde_json::from_str(&first).unwrap(); + assert_eq!(reply["error"]["code"], -32600, "{first}"); + let mut next = String::new(); + let closed = match r.read_line(&mut next) { + Ok(n) => n == 0, + Err(e) => matches!(e.kind(), ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted), + }; + assert!(closed, "the connection stayed open: {next:?}"); + } + + /// After `auth` the limit no longer applies: a long line is read as before (here it isn't + /// JSON, so it gets a parse error and the connection stays open). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn long_lines_after_auth_are_still_read() { + use std::io::{BufRead, BufReader, Write}; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve(client).unwrap(); + let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); + s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut w = s.try_clone().unwrap(); + let mut r = BufReader::new(s); + let reply = |r: &mut BufReader| { + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + serde_json::from_str::(&line).unwrap() + }; + let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); + writeln!(w, "{auth}").unwrap(); + assert_eq!(reply(&mut r)["result"]["ok"], true); + writeln!(w, "{}", "x".repeat(4 * super::MAX_AUTH_LINE)).unwrap(); + assert_eq!(reply(&mut r)["error"]["code"], -32700, "a long line after auth is parsed, not refused"); + writeln!(w, "y").unwrap(); + assert_eq!(reply(&mut r)["error"]["code"], -32700, "and the connection is still open"); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 499d0e4f8e..6eceff17e1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -19,6 +19,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); link_prompt(app, ctx); + remote_link_prompt(app, ctx); crate::updates::dialog(app, ctx); let Some(dialog) = app.dialog else { app.props_draft = None; @@ -1098,7 +1099,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { widgets::artcraft_mark(ui, 40.0); ui.vertical(|ui| { ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); - ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", env!("CARGO_PKG_VERSION"))])); + ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); }); }); ui.add_space(6.0); @@ -1333,7 +1334,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.run_compare(); } if a11y_now { - app.a11y_skipped.clear(); + if let Some((_, id)) = app.active_ids() { + app.a11y_skipped.retain(|(doc, _)| *doc != id); + } app.run_accessibility_check(); } } @@ -1509,6 +1512,38 @@ fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { } } +/// Absolute document-controlled local paths require consent before any filesystem access. +fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { + let Some(pending) = app.pending_remote_link.clone() else { return }; + let mut choice = None; + let modal = widgets::show_modal(ctx, "remote_link_prompt", |ui| { + ui.set_width(460.0); + ui.label(egui::RichText::new(tl!("Open this local PDF?")).font(theme::semibold(16.0))); + ui.add_space(6.0); + ui.label(tl!("This document is trying to open a local file:")); + egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true)); + }); + ui.add_space(8.0); + ui.label(tl!("Only allow this if you trust the source document.")); + ui.add_space(14.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Cancel"), true).clicked() { + choice = Some(false); + } + if widgets::pill_button(ui, tl!("Allow"), false).clicked() { + choice = Some(true); + } + }); + }); + if choice.is_none() && modal.should_close() { + choice = Some(false); + } + if let Some(open) = choice { + app.resolve_pending_remote_link(open); + } +} + fn yes(b: bool) -> String { tl!(if b { "Yes" } else { "No" }).to_string() } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index caf2d4c438..fe4c0390df 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -451,6 +451,14 @@ fn panel_limits(panel: &Panel) -> PanelLimits { mod tests { use super::*; + #[test] + fn lithuanian_translates_every_dynamic_panel_title() { + let lang = crate::i18n::Lang::from_code("lt").expect("Lithuanian is registered"); + for panel in Panel::ALL { + assert_ne!(crate::i18n::tr(lang, panel.label()), panel.label(), "{} remains untranslated", panel.id()); + } + } + #[test] fn tools_side_moves_to_both_edges_and_survives_closed_restore() { let mut workspace = Workspace::default(); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 2352249b5a..65c50528ec 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -319,6 +319,12 @@ pub struct ImageSelection { drag: Option<(Pos2, Option)>, } +impl ImageSelection { + pub(crate) fn dragging(&self) -> bool { + self.drag.is_some() + } +} + /// Once a selected corner is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6410898ce4..9b1b35b198 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -43,6 +43,7 @@ impl PdfCraftApp { let info = &doc.info; let view = &mut self.views[i]; view.signature_drag.committed(&edit, doc.edit_generation()); + view.move_background.committed(&edit, doc.edit_generation()); if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) { // Comment and content edits change one page: keep every other raster. view.page_changed(page); @@ -922,6 +923,17 @@ pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> { /// [`write_atomically`], trying the staging names that `suffixes` give. fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator) -> std::io::Result<()> { + write_atomically_syncing(path, bytes, suffixes, std::fs::File::sync_all) +} + +/// [`write_atomically_with`], flushing the staging file with `sync_all` (the standard library's +/// in the app, a failing one in tests). +fn write_atomically_syncing( + path: &str, + bytes: &[u8], + suffixes: impl IntoIterator, + sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> 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(".")); @@ -932,7 +944,7 @@ fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator std::io::Result<()>) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + { + use rustix::io::Errno; + match sync_all(f) { + Err(e) if matches!(Errno::from_io_error(&e), Some(Errno::NOTSUP | Errno::NOTTY)) => Ok(rustix::fs::fsync(f)?), + other => other, + } + } + #[cfg(not(target_os = "macos"))] + sync_all(f) +} + #[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()) @@ -1027,7 +1056,7 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> #[cfg(test)] mod tests { - use super::{SaveTarget, is_mail_temp_copy, write_atomically, write_atomically_with}; + use super::{SaveTarget, is_mail_temp_copy, write_atomically, write_atomically_syncing, write_atomically_with}; use pdfcraft_platform::staging::{STAGING_ATTEMPTS, staging_suffixes}; use std::path::{Path, PathBuf}; @@ -1184,6 +1213,43 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// On macOS `sync_all` asks for a full flush, which SMB and FUSE volumes (a Cryptomator vault, + /// #981) refuse as unsupported: ENOTSUP (45) or ENOTTY (25). The save still goes through. + #[cfg(target_os = "macos")] + #[test] + fn a_volume_without_full_flush_still_saves() { + for errno in [45, 25] { + let dir = staging_dir(&format!("nofullsync-{errno}")); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let asked = std::cell::Cell::new(false); + let refuse = |_: &std::fs::File| { + asked.set(true); + Err(std::io::Error::from_raw_os_error(errno)) + }; + write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], refuse).unwrap(); + assert!(asked.get(), "the full flush was tried"); + assert_eq!(read(&target), "NEW"); + assert_eq!(listing(&dir), ["out.pdf"], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); + } + } + + /// Only "not supported" is let through: any other flush failure fails the save and keeps the + /// original. + #[test] + fn a_failed_flush_keeps_the_original() { + let dir = staging_dir("syncfails"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let e = write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], |_| Err(std::io::Error::other("disk gone"))).unwrap_err(); + assert_eq!(e.to_string(), "disk gone"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn outlook_attachment_copies_are_recognised_on_either_separator_and_in_any_case() { for p in [ diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index cd54c775f6..bbeb20727f 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -190,6 +190,28 @@ pub(crate) fn file_stem_from_user(name: &str) -> String { if reserved { format!("_{stem}") } else { stem.to_string() } } +/// A safe native Save suggestion for a PDF-supplied attachment name. +/// This changes only the suggestion, never the stored name or the selected destination. +#[cfg(any(test, not(target_arch = "wasm32")))] +pub(crate) fn attachment_file_name(name: &str) -> String { + let cleaned: String = name + .chars() + .filter(|c| !c.is_control() && !matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')) + .map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }) + .collect(); + let name = cleaned.trim_start().trim_end_matches(|c: char| c == '.' || c.is_whitespace()); + if name.is_empty() { + return "attachment".into(); + } + let device = name.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase(); + let reserved = matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || device + .strip_prefix("COM") + .or_else(|| device.strip_prefix("LPT")) + .is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³")); + if reserved { format!("_{name}") } else { name.to_string() } +} + /// Pages ▸ Rotate Pages. #[derive(Clone, Debug, PartialEq)] pub struct RotateDraft { @@ -352,7 +374,7 @@ impl PdfCraftApp { let incoming = files .into_iter() .zip(modified) - .map(|((name, bytes), modified)| crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified, note: None }) + .map(|((name, bytes), modified)| crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified, note: None, open_doc: None }) .collect(); self.stage_combine_with(incoming); } else { @@ -969,7 +991,7 @@ impl PdfCraftApp { #[cfg(test)] mod stem_tests { - use super::file_stem_from_user; + use super::{attachment_file_name, file_stem_from_user}; #[test] fn user_file_names_stay_inside_the_folder_and_writable_everywhere() { @@ -986,4 +1008,40 @@ mod stem_tests { let long = file_stem_from_user(&"文".repeat(300)); assert!(long.len() <= 200 && long.chars().all(|c| c == '文'), "{}", long.len()); } + #[test] + fn attachment_save_name_removes_bidi_without_hiding_the_extension() { + assert_eq!(attachment_file_name("invoice\u{202e}fdp.exe"), "invoicefdp.exe"); + for c in [ + '\u{061c}', '\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', + '\u{2069}', + ] { + assert_eq!(attachment_file_name(&format!("a{c}b.pdf")), "ab.pdf"); + } + } + #[test] + fn attachment_save_name_preserves_ordinary_unicode_and_extensions() { + for name in ["report.pdf", "archive.tar.gz", "مرحبا.pdf", "שלום.txt", "資料.pdf", "می\u{200c}روم.txt", "👩\u{200d}💻.png", "COM10.txt"] + { + assert_eq!(attachment_file_name(name), name); + } + } + #[test] + fn attachment_save_name_handles_controls_paths_and_device_names() { + assert_eq!(attachment_file_name("a\u{0}\n\u{85}b.pdf"), "ab.pdf"); + assert_eq!(attachment_file_name("../a\\b:c*?\"<>|.pdf"), ".._a_b_c______.pdf"); + for name in ["NUL.tar.gz", "con.txt", "COM1.exe", "LPT9.txt", "COM¹.txt", "LPT³.pdf"] { + assert_eq!(attachment_file_name(name), format!("_{name}")); + } + } + #[test] + fn attachment_save_name_has_a_stable_nonempty_fallback() { + for name in ["", " ", ".", "..", " . . ", "\u{202e}", "\u{0}\n"] { + assert_eq!(attachment_file_name(name), "attachment"); + } + assert_eq!(attachment_file_name(" report.pdf . \u{2003}."), "report.pdf"); + for name in ["invoice\u{202e}fdp.exe", "../a.pdf", "NUL.txt", " . . ", "report.pdf . ", "資料.pdf"] { + let once = attachment_file_name(name); + assert_eq!(attachment_file_name(&once), once); + } + } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 12068f14f3..339389b182 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -218,7 +218,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { - ui.label(egui::RichText::new(d).small()); + ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } ui.end_row(); for cell in 0..(first + len).div_ceil(7) * 7 { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8163ee30ad..222865d72b 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -855,6 +855,7 @@ , Expires: ، تنتهي: No digital IDs yet. لا توجد معرّفات رقمية بعد. Configure New Digital ID إعداد معرّف رقمي جديد + Remove "{name}" from this list? The file itself is not deleted. إزالة "{name}" من هذه القائمة؟ لن يُحذف الملف نفسه. Continue متابعة Configure a Digital ID for Signing إعداد معرّف رقمي للتوقيع Use a Digital ID from a file استخدام معرّف رقمي من ملف @@ -2359,3 +2360,16 @@ animation effects Off معطّلة The linked PDF opened, but its named destination was not applied. فُتح ملف PDF المرتبط، لكن لم تُطبَّق وجهته المسماة. The linked PDF opened, but its page destination was out of range. فُتح ملف PDF المرتبط، لكن وجهة الصفحة خارج النطاق. Local cross-PDF links require the desktop app تتطلب الروابط المحلية بين ملفات PDF تطبيق سطح المكتب + Minimize تصغير + Maximize تكبير + Restore استعادة + Use system title bar استخدام شريط عنوان النظام + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. إظهار شريط عنوان بسيط لأزرار النافذة بدلًا من شريط الألسنة. لا يرسم GNOME أشرطة العناوين للتطبيقات، لذلك لا يتبع هذا الشريط سمته. + +# Absolute local GoToR confirmation (#1364). + Allow السماح + Only allow this if you trust the source document. لا تسمح بذلك إلا إذا كنت تثق بالمستند المصدر. + Open this local PDF? هل تريد فتح ملف PDF المحلي هذا؟ + This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: + Move tag earlier نقل العلامة للأعلى + Move tag later نقل العلامة للأسفل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index febd1f9cec..17a0890fa5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -530,6 +530,7 @@ , Expires: , валиден до: No digital IDs yet. Все още няма цифрови идентификатори. Configure New Digital ID Конфигурирай нов цифров идентификатор + Remove "{name}" from this list? The file itself is not deleted. Да се премахне ли „{name}“ от този списък? Самият файл няма да бъде изтрит. Continue Продължи Configure a Digital ID for Signing Конфигуриране на цифров идентификатор за подписване Use a Digital ID from a file Използвай цифров идентификатор от файл @@ -2301,3 +2302,16 @@ animation effects Off Изключени The linked PDF opened, but its named destination was not applied. Свързаният PDF се отвори, но именуваната му цел не беше приложена. The linked PDF opened, but its page destination was out of range. Свързаният PDF се отвори, но целевата му страница е извън обхвата. Local cross-PDF links require the desktop app Локалните връзки между PDF файлове изискват настолното приложение + Minimize Минимизиране + Maximize Максимизиране + Restore Възстановяване + Use system title bar Използване на системната заглавна лента + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Показване на обикновена заглавна лента за бутоните на прозореца вместо лентата с раздели. GNOME не рисува заглавни ленти за приложенията, затова тази не следва темата му. + +# Absolute local GoToR confirmation (#1364). + Allow Разреши + Only allow this if you trust the source document. Разрешете това само ако имате доверие на изходния документ. + Open this local PDF? Да се отвори ли този локален PDF? + This document is trying to open a local file: Този документ се опитва да отвори локален файл: + Move tag earlier Преместване на етикета по-рано + Move tag later Преместване на етикета по-късно diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ee4f9bcb96..e6e29c8bba 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -929,6 +929,7 @@ key Space Mezerník , Expires: , platnost do: No digital IDs yet. Zatím žádná digitální ID. Configure New Digital ID Nastavit nové digitální ID + Remove "{name}" from this list? The file itself is not deleted. Odebrat „{name}“ z tohoto seznamu? Samotný soubor se nesmaže. Continue Pokračovat Configure a Digital ID for Signing Nastavení digitálního ID pro podepisování Use a Digital ID from a file Použít digitální ID ze souboru @@ -2412,3 +2413,16 @@ animation effects Off Vypnuto The linked PDF opened, but its named destination was not applied. Odkazovaný PDF se otevřel, ale jeho pojmenovaný cíl nebyl použit. The linked PDF opened, but its page destination was out of range. Odkazovaný PDF se otevřel, ale cílová stránka je mimo rozsah. Local cross-PDF links require the desktop app Místní odkazy mezi soubory PDF vyžadují desktopovou aplikaci + Minimize Minimalizovat + Maximize Maximalizovat + Restore Obnovit + Use system title bar Použít systémové záhlaví okna + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Zobrazit jednoduché záhlaví s tlačítky okna místo pruhu karet. GNOME aplikacím záhlaví nekreslí, proto toto záhlaví nesleduje jeho motiv. + +# Absolute local GoToR confirmation (#1364). + Allow Povolit + Only allow this if you trust the source document. Povolte tuto akci pouze v případě, že důvěřujete zdrojovému dokumentu. + Open this local PDF? Otevřít tento místní soubor PDF? + This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: + Move tag earlier Přesunout značku dříve + Move tag later Přesunout značku později diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7811a78ee9..38ef665d13 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -833,6 +833,7 @@ , Expires: , läuft ab: No digital IDs yet. Noch keine digitalen IDs. Configure New Digital ID Neue digitale ID einrichten + Remove "{name}" from this list? The file itself is not deleted. „{name}“ aus dieser Liste entfernen? Die Datei selbst wird nicht gelöscht. Continue Weiter Configure a Digital ID for Signing Digitale ID zum Signieren einrichten Use a Digital ID from a file Digitale ID aus einer Datei verwenden @@ -2339,3 +2340,16 @@ animation effects Off Aus The linked PDF opened, but its named destination was not applied. Die verknüpfte PDF wurde geöffnet, aber ihr benanntes Ziel wurde nicht angewendet. The linked PDF opened, but its page destination was out of range. Die verknüpfte PDF wurde geöffnet, aber ihr Seitenziel liegt außerhalb des Bereichs. Local cross-PDF links require the desktop app Lokale Verknüpfungen zwischen PDFs erfordern die Desktop-App + Minimize Minimieren + Maximize Maximieren + Restore Wiederherstellen + Use system title bar System-Titelleiste verwenden + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Eine einfache Titelleiste für die Fensterknöpfe statt der Tableiste anzeigen. GNOME zeichnet für Apps keine Titelleisten, daher folgt diese nicht seinem Design. + +# Absolute local GoToR confirmation (#1364). + Allow Zulassen + Only allow this if you trust the source document. Lassen Sie dies nur zu, wenn Sie dem Quelldokument vertrauen. + Open this local PDF? Diese lokale PDF-Datei öffnen? + This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: + Move tag earlier Tag früher verschieben + Move tag later Tag später verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a5030e6aa..106278f012 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -464,6 +464,7 @@ , Expires: , Λήξη: No digital IDs yet. Δεν υπάρχουν ακόμη ψηφιακές ταυτότητες. Configure New Digital ID Ρύθμιση νέας ψηφιακής ταυτότητας + Remove "{name}" from this list? The file itself is not deleted. Αφαίρεση του «{name}» από αυτή τη λίστα; Το ίδιο το αρχείο δεν διαγράφεται. Configure a Digital ID for Signing Ρύθμιση ψηφιακής ταυτότητας για υπογραφή Use a Digital ID from a file Χρήση ψηφιακής ταυτότητας από αρχείο Create a new Digital ID (self-signed, saved to a password-protected file) Δημιουργία νέας ψηφιακής ταυτότητας (αυτοϋπογεγραμμένης, αποθηκευμένης σε αρχείο με κωδικό πρόσβασης) @@ -2348,3 +2349,16 @@ animation effects Off Ανενεργά Local cross-PDF links require the desktop app Οι τοπικοί σύνδεσμοι μεταξύ PDF απαιτούν την εφαρμογή υπολογιστή The existing action will be preserved. Η υπάρχουσα ενέργεια θα διατηρηθεί. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Τα σενάρια αυτής της φόρμας είναι απενεργοποιημένα, επειδή ένα από αυτά εκτελούνταν για πολύ ώρα. Ανοίξτε ξανά το έγγραφο για να εκτελεστούν ξανά. + Minimize Ελαχιστοποίηση + Maximize Μεγιστοποίηση + Restore Επαναφορά + Use system title bar Χρήση γραμμής τίτλου συστήματος + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Εμφάνιση απλής γραμμής τίτλου για τα κουμπιά του παραθύρου αντί για τη γραμμή καρτελών. Το GNOME δεν σχεδιάζει γραμμές τίτλου για τις εφαρμογές, οπότε αυτή δεν ακολουθεί το θέμα του. + +# Absolute local GoToR confirmation (#1364). + Allow Να επιτραπεί + Only allow this if you trust the source document. Επιτρέψτε το μόνο αν εμπιστεύεστε το έγγραφο προέλευσης. + Open this local PDF? Να ανοίξει αυτό το τοπικό PDF; + This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: + Move tag earlier Μετακίνηση ετικέτας νωρίτερα + Move tag later Μετακίνηση ετικέτας αργότερα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba79a5b8e0..5afbe4f2ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -849,6 +849,7 @@ , Expires: , caduca: No digital IDs yet. Aún no hay ningún ID digital. Configure New Digital ID Configurar un nuevo ID digital + Remove "{name}" from this list? The file itself is not deleted. ¿Quitar «{name}» de esta lista? El archivo en sí no se elimina. Continue Continuar Configure a Digital ID for Signing Configurar un ID digital para firmar Use a Digital ID from a file Usar un ID digital de un archivo @@ -2468,3 +2469,16 @@ animation effects Off Desactivados The linked PDF opened, but its page destination was out of range. El PDF vinculado se abrió, pero su página de destino está fuera de rango. Local cross-PDF links require the desktop app Los enlaces locales entre PDF requieren la aplicación de escritorio The existing action will be preserved. Se conservará la acción existente. + Minimize Minimizar + Maximize Maximizar + Restore Restaurar + Use system title bar Usar la barra de título del sistema + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Mostrar una barra de título básica para los botones de la ventana en lugar de la barra de pestañas. GNOME no dibuja barras de título para las aplicaciones, así que esta no sigue su tema. + +# Absolute local GoToR confirmation (#1364). + Allow Permitir + Only allow this if you trust the source document. Permite esta acción solo si confías en el documento de origen. + Open this local PDF? ¿Abrir este PDF local? + This document is trying to open a local file: Este documento intenta abrir un archivo local: + Move tag earlier Mover etiqueta antes + Move tag later Mover etiqueta después diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e81b5f01e1..79389837d3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -829,6 +829,7 @@ , Expires: , expire : No digital IDs yet. Aucun ID numérique pour l'instant. Configure New Digital ID Configurer un nouvel ID numérique + Remove "{name}" from this list? The file itself is not deleted. Retirer « {name} » de cette liste ? Le fichier lui-même n’est pas supprimé. Continue Continuer Configure a Digital ID for Signing Configurer un ID numérique pour signer Use a Digital ID from a file Utiliser un ID numérique provenant d'un fichier @@ -2338,3 +2339,16 @@ animation effects Off Désactivés The linked PDF opened, but its named destination was not applied. Le PDF lié s'est ouvert, mais sa destination nommée n'a pas été appliquée. The linked PDF opened, but its page destination was out of range. Le PDF lié s'est ouvert, mais sa page de destination est hors limites. Local cross-PDF links require the desktop app Les liens locaux entre PDF nécessitent l'application de bureau + Minimize Réduire + Maximize Agrandir + Restore Restaurer + Use system title bar Utiliser la barre de titre du système + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Afficher une barre de titre simple pour les boutons de la fenêtre au lieu de la barre d’onglets. GNOME ne dessine pas de barre de titre pour les applications : celle-ci ne suit donc pas son thème. + +# Absolute local GoToR confirmation (#1364). + Allow Autoriser + Only allow this if you trust the source document. N'autorisez cette action que si vous faites confiance au document source. + Open this local PDF? Ouvrir ce PDF local ? + This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : + Move tag earlier Déplacer la balise plus tôt + Move tag later Déplacer la balise plus tard diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b9b3f558d4..3d6e1e3af5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -826,6 +826,7 @@ , Expires: , lejár: No digital IDs yet. Még nincs digitális azonosító. Configure New Digital ID Új digitális azonosító beállítása + Remove "{name}" from this list? The file itself is not deleted. Eltávolítja a(z) „{name}” elemet erről a listáról? Maga a fájl nem törlődik. Continue Tovább Configure a Digital ID for Signing Digitális azonosító beállítása aláíráshoz Use a Digital ID from a file Digitális azonosító használata fájlból @@ -2337,3 +2338,16 @@ animation effects Off Ki The linked PDF opened, but its named destination was not applied. A hivatkozott PDF megnyílt, de a nevesített cél nem lett alkalmazva. The linked PDF opened, but its page destination was out of range. A hivatkozott PDF megnyílt, de a céloldal a tartományon kívül esik. Local cross-PDF links require the desktop app A PDF-ek közötti helyi hivatkozásokhoz az asztali alkalmazás szükséges + Minimize Minimalizálás + Maximize Maximalizálás + Restore Visszaállítás + Use system title bar Rendszer címsorának használata + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Egyszerű címsor megjelenítése az ablakgombokkal a lapsáv helyett. A GNOME nem rajzol címsort az alkalmazásoknak, ezért ez nem követi a témáját. + +# Absolute local GoToR confirmation (#1364). + Allow Engedélyezés + Only allow this if you trust the source document. Csak akkor engedélyezze ezt, ha megbízik a forrásdokumentumban. + Open this local PDF? Megnyitja ezt a helyi PDF-fájlt? + This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: + Move tag earlier Címke korábbra helyezése + Move tag later Címke későbbre helyezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5448ef6396..3575ab374d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -852,6 +852,7 @@ , Expires: , Scadenza: No digital IDs yet. Ancora nessun ID digitale. Configure New Digital ID Configura nuovo ID digitale + Remove "{name}" from this list? The file itself is not deleted. Rimuovere "{name}" da questo elenco? Il file non viene eliminato. Continue Continua Configure a Digital ID for Signing Configura un ID digitale per la firma Use a Digital ID from a file Usa un ID digitale da file @@ -2347,3 +2348,16 @@ animation effects Off Disattivati The linked PDF opened, but its named destination was not applied. Il PDF collegato è stato aperto, ma la sua destinazione con nome non è stata applicata. The linked PDF opened, but its page destination was out of range. Il PDF collegato è stato aperto, ma la pagina di destinazione è fuori intervallo. Local cross-PDF links require the desktop app I collegamenti locali tra PDF richiedono l'app desktop + Minimize Riduci a icona + Maximize Ingrandisci + Restore Ripristina + Use system title bar Usa la barra del titolo di sistema + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Mostra una barra del titolo semplice per i pulsanti della finestra invece della barra delle schede. GNOME non disegna le barre del titolo per le app, quindi questa non segue il suo tema. + +# Absolute local GoToR confirmation (#1364). + Allow Consenti + Only allow this if you trust the source document. Consenti questa operazione solo se ti fidi del documento di origine. + Open this local PDF? Aprire questo PDF locale? + This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: + Move tag earlier Sposta tag prima + Move tag later Sposta tag dopo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c4277720aa..5e61802c35 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -856,6 +856,7 @@ , Expires: 、有効期限: No digital IDs yet. デジタル ID はまだありません。 Configure New Digital ID 新しいデジタル ID を設定 + Remove "{name}" from this list? The file itself is not deleted. 「{name}」をこの一覧から削除しますか?ファイル自体は削除されません。 Continue 続行 Configure a Digital ID for Signing 署名用のデジタル ID を設定 Use a Digital ID from a file ファイルのデジタル ID を使用 @@ -2338,3 +2339,16 @@ animation effects Off オフ The linked PDF opened, but its named destination was not applied. リンク先の PDF を開きましたが、名前付き移動先は適用されませんでした。 The linked PDF opened, but its page destination was out of range. リンク先の PDF を開きましたが、移動先のページが範囲外です。 Local cross-PDF links require the desktop app PDF 間のローカルリンクにはデスクトップアプリが必要です + Minimize 最小化 + Maximize 最大化 + Restore 元のサイズに戻す + Use system title bar システムのタイトルバーを使う + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. タブバーの代わりに、ウィンドウボタン用のシンプルなタイトルバーを表示します。GNOME はアプリのタイトルバーを描画しないため、このタイトルバーは GNOME のテーマに従いません。 + +# Absolute local GoToR confirmation (#1364). + Allow 許可 + Only allow this if you trust the source document. 元の文書を信頼できる場合にのみ許可してください。 + Open this local PDF? このローカルPDFを開きますか? + This document is trying to open a local file: この文書はローカルファイルを開こうとしています: + Move tag earlier タグを前へ移動 + Move tag later タグを後へ移動 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc807c78d5..8e4a090403 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -811,6 +811,7 @@ , Expires: , Жарамдылық мерзімі: No digital IDs yet. Әзірге цифрлық ID жоқ. Configure New Digital ID Жаңа цифрлық ID теңшеу + Remove "{name}" from this list? The file itself is not deleted. «{name}» осы тізімнен алып тасталсын ба? Файлдың өзі жойылмайды. Continue Жалғастыру Configure a Digital ID for Signing Қол қоюға арналған цифрлық ID теңшеу Use a Digital ID from a file Файлдағы цифрлық ID-ді пайдалану @@ -2336,3 +2337,16 @@ animation effects Off Өшірулі The linked PDF opened, but its named destination was not applied. Байланысты PDF ашылды, бірақ оның аталған мақсаты қолданылмады. The linked PDF opened, but its page destination was out of range. Байланысты PDF ашылды, бірақ оның мақсатты беті ауқымнан тыс. Local cross-PDF links require the desktop app PDF файлдары арасындағы жергілікті сілтемелер үшін жұмыс үстелі қолданбасы қажет + Minimize Жию + Maximize Жаю + Restore Қалпына келтіру + Use system title bar Жүйелік тақырып жолағын пайдалану + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Терезе батырмалары үшін қойындылар жолағының орнына қарапайым тақырып жолағын көрсету. GNOME қолданбаларға тақырып жолағын салмайды, сондықтан бұл жолақ оның тақырыбына сәйкес келмейді. + +# Absolute local GoToR confirmation (#1364). + Allow Рұқсат беру + Only allow this if you trust the source document. Бастапқы құжатқа сенсеңіз ғана рұқсат беріңіз. + Open this local PDF? Осы жергілікті PDF файлын ашу керек пе? + This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: + Move tag earlier Тегті ертерек жылжыту + Move tag later Тегті кейінірек жылжыту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv new file mode 100644 index 0000000000..ae61e396ff --- /dev/null +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -0,0 +1,2598 @@ +# Lithuanian (lt) catalog. Columns, separated by a TAB: context source translation +# +# Clean-room translations from English source labels. Use standard Lithuanian computing terms; +# consult the Lietuvos Respublikos terminų bankas (terminai.vlkk.lt) when selecting terminology. +# Product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain unchanged. + + Menu Meniu + Edit Redaguoti + Pages Puslapiai + View Rodinys + Help Žinynas + Zoom Mastelis + Actual size Faktinis dydis + Zoom to page level Pritaikyti puslapiui + Fit to width Pritaikyti pločiui + Display theme Išvaizdos tema + Side panels Šoniniai skydeliai + Preferences Nuostatos + Preferences… Nuostatos… + Interface language Sąsajos kalba + Interface size Sąsajos dydis + Auto ({percent}%) Automatiškai ({percent} %) + Identity Tapatybė + Name on new comments Naujų komentarų autoriaus vardas + Open… Atverti… + Open Recent Atverti paskiausiai naudotus failus + Clear Recent Files Išvalyti paskiausiai naudotų failų sąrašą + Remove from Recent Pašalinti iš paskiausiai naudotų failų + No recent files Nėra paskiausiai naudotų failų + New blank PDF Naujas tuščias PDF dokumentas + Create PDF from file… Sukurti PDF iš failo… + Create PDF from images… Sukurti PDF iš paveikslėlių… + Create PDF from multiple files… Sukurti PDF iš kelių failų… + Create separate PDFs… Sukurti atskirus PDF dokumentus… + Create PDF from clipboard Sukurti PDF iš iškarpinės + Combine files… Sujungti failus… + Save Įrašyti + Save as… Įrašyti kaip… + Close file Užverti failą + Close all Užverti visus + Revert Grąžinti įrašytą versiją + Print… Spausdinti… + Document properties… Dokumento savybės… + Find… Ieškoti… + Advanced search… Išplėstinė paieška… + Copy Kopijuoti + Cut Iškirpti + Paste Įdėti + Delete Naikinti + Select all Pažymėti viską + Cancel Atšaukti + OK Gerai + Apply Taikyti + Close Užverti + Back Atgal + Next Pirmyn + Previous Ankstesnis + Name Pavadinimas + File name Failo pavadinimas + Location Vieta + Browse… Naršyti… + Choose… Pasirinkti… + Search Ieškoti + Clear Išvalyti + Refresh Atnaujinti + Loading… Įkeliama… + Save changes? Įrašyti pakeitimus? + Don't save Neįrašyti + Page Puslapis + Page {n} {n} puslapis + of iš + Fit one full page Pritaikyti visą puslapį + Single page Vienas puslapis + Two-page view Dviejų puslapių rodinys + Continuous scrolling Nepertraukiamas slinkimas + Page display Puslapių rodymas + Default page display Numatytasis puslapių rodymas + Default zoom Numatytasis mastelis + Full screen mode Viso ekrano veiksena + Read mode Skaitymo veiksena + Comments Komentarai + Comment Komentaras + Add comment Pridėti komentarą + Delete comment Naikinti komentarą + Reply Atsakyti + Highlight Paryškinti + Underline Pabraukti + Strikethrough Perbraukti + Text Tekstas + Font Šriftas + Size Dydis + Color Spalva + Opacity Nepermatomumas + Width Plotis + Height Aukštis + Rotation Pasukimas + More Daugiau + Settings Nuostatos + General Bendrieji + Language Kalba + Appearance Išvaizda + Theme Tema + Light Šviesi + Dark Tamsi + System Pagal sistemą + Open file Atverti failą + Save file Įrašyti failą + Export Eksportuoti + Import Importuoti + Export to image… Eksportuoti kaip paveikslėlį… + Export to text… Eksportuoti kaip tekstą… + Export to Word… Eksportuoti į Word… + Export to HTML… Eksportuoti į HTML… + Export to RTF… Eksportuoti į RTF… + Reduce file size… Sumažinti failo dydį… + Optimize PDF… Optimizuoti PDF… + Protect using password… Apsaugoti slaptažodžiu… + Remove security Pašalinti apsaugą + Security properties… Apsaugos savybės… + Organize pages Tvarkyti puslapius + Rotate pages… Pasukti puslapius… + Delete pages Naikinti puslapius + Insert blank page Įterpti tuščią puslapį + Insert pages from file… Įterpti puslapius iš failo… + Replace pages… Pakeisti puslapius… + Extract pages… Išskirti puslapius… + Split document… Skaidyti dokumentą… + Number pages… Numeruoti puslapius… + Bookmarks Žymelės + New bookmark Nauja žymelė + Forms Formos + Form fields Formos laukai + Signatures Parašai + Sign Pasirašyti + Fill & Sign Pildyti ir pasirašyti + Accessibility Prieinamumas + Help… Žinynas… + Check for updates… Tikrinti, ar yra naujinimų… + About PdfCraft Apie „PdfCraft“ + Automatically Automatiškai + Never Niekada + Always Visada + Yes Taip + No Ne + On Įjungta + Off Išjungta + Enabled Įjungta + Disabled Išjungta + Required Privaloma + Optional Pasirenkama + None Nėra + Unknown Nežinoma + Error Klaida + Warning Įspėjimas + Information Informacija + Success Pavyko + Failed Nepavyko + File Failas + Folder Aplankas + Choose a file… Pasirinkti failą… + Choose a folder… Pasirinkti aplanką… + Open folder Atverti aplanką + Create folder Sukurti aplanką + Remove Pašalinti + Rename Pervadinti + Duplicate Dubliuoti + Move Perkelti + Copy to clipboard Kopijuoti į iškarpinę + Copied to clipboard Nukopijuota į iškarpinę + Undo Anuliuoti + Redo Pakartoti + Keyboard shortcuts Spartieji klavišai + Find tools and commands… Ieškoti įrankių ir komandų… + Take a snapshot Padaryti momentinę kopiją + Organize Tvarkyti + Comment and review Komentuoti ir peržiūrėti + Prepare a form Rengti formą + Protect Apsaugoti + Print Spausdinti + Create Kurti + Convert Konvertuoti + Optimize Optimizuoti + Measure Matuoti + Redact Pašalinti konfidencialią informaciją + Search results Paieškos rezultatai + No results Rezultatų nėra + Page {n} of {total} Puslapis {n} iš {total} + {n} pages selected Pažymėta puslapių: {n} + · not fixable here · čia nepataisoma + (page {p}) (puslapis {p}) + (searching… {s} of {p} pages) (ieškoma… {s} iš {p} puslapių) + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n}, {dpi} dpi – didžiausias galimas vaizdo dydis) + ({s} not exported: {first}) ({s} neeksportuota: {first}) + on page {p} puslapyje {p} + · password-protected · apsaugotas slaptažodžiu + (none) (nėra) + , Expires: , galiojimo pabaiga: + 1 difference found Rastas 1 skirtumas + 1 file 1 failas + 1 file done 1 failas apdorotas + 1 file needs attention 1 failui reikia dėmesio + 1 instance 1 egzempliorius + 1 issue 1 problema + 1 mark 1 žyma + 1 page 1 puslapis + 1 page selected Pažymėtas 1 puslapis + 1 page selected. Pažymėtas 1 puslapis. + 180 degrees 180 laipsnių + 9 digit, A letter, O letter or digit, X any character 9 – skaitmuo, A – raidė, O – raidė arba skaitmuo, X – bet kuris simbolis + ; 1 page already had text ; 1 puslapyje tekstas jau buvo + ; failed: {list} ; nepavyko: {list} + ; {n} pages already had text ; {n} puslapiuose tekstas jau buvo + ? of {n} ? iš {n} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Švarių patalpų principu kuriama atvirojo kodo PDF programa, parašyta „Rust“ kalba. MIT OR Apache-2.0. + Accessibility Checker Prieinamumo tikrintuvas + Accessibility Checker Options Prieinamumo tikrintuvo parinktys + Acrobat 3.0 and later (40-bit RC4) „Acrobat 3.0“ ir naujesnės versijos (40 bitų RC4) + Acrobat 5.0 and later (128-bit RC4) „Acrobat 5.0“ ir naujesnės versijos (128 bitų RC4) + Acrobat 7.0 and later (128-bit AES) „Acrobat 7.0“ ir naujesnės versijos (128 bitų AES) + Acrobat X and later (256-bit AES) „Acrobat X“ ir naujesnės versijos (256 bitų AES) + Action name: Veiksmo pavadinimas: + Actions Veiksmai + Active Aktyvi + Actual Size Faktinis dydis + Actual text Faktinis tekstas + Add Pridėti + Add Step Pridėti veiksmą + Add a comment Pridėti komentarą + Add a reply Atsakyti + Add a sticky note here Čia pridėti lipnųjį raštelį + Add an Action Pridėti veiksmą + Add at least two files Pridėkite bent du failus + Add files… Pridėti failus… + Add folder Pridėti aplanką + Add folder and subfolders Pridėti aplanką ir jo poaplankius + Add folder and subfolders… Pridėti aplanką ir jo poaplankius… + Add folder… Pridėti aplanką… + Add open documents Pridėti atvertus dokumentus + Add the choices people pick from. Pridėkite pasirinkimus, kuriuos galės rinktis naudotojai. + Add the files to combine Pridėkite jungiamus failus + Add the steps this action runs, in order. Pridėkite veiksmus, kuriuos šis veiksmas vykdys iš eilės. + Add to Trusted Certificates Pridėti prie patikimų sertifikatų + Add to trusted certificates Pridėti prie patikimų sertifikatų + Added 1 comment Pridėtas 1 komentaras + Added more than once Pridėta daugiau nei vieną kartą + Added {n} comments Pridėta komentarų: {n} + Adds invisible text over each word; the page image is not changed Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia + Advanced Išplėstiniai + Advanced Options {chevron} Išplėstinės parinktys {chevron} + Align Lygiuoti + Align left Lygiuoti kairėje + Align right Lygiuoti dešinėje + Alignment: Lygiuotė: + All Visi + All document contents Visas dokumento turinys + All document contents except metadata Visas dokumento turinys, išskyrus metaduomenis + All open documents Visi atverti dokumentai + All pages Visi puslapiai + All pages in document Visi dokumento puslapiai + All pages in range Visi intervalo puslapiai + Allow Rich Text Formatting Leisti raiškiojo teksto formatavimą + Allow user to enter custom text Leisti naudotojui įvesti pasirinktinį tekstą + Also recognize pages that already have text Taip pat atpažinti puslapius, kuriuose jau yra teksto + Alternate text Alternatyvusis tekstas + An action with this name already exists. Veiksmas tokiu pavadinimu jau yra. + An open-source PDF workbench — local, private, and scriptable. Atvirojo kodo PDF aplinka — vietinė, privati ir valdoma scenarijais. + Angle Kampas + Animation effects Animacijos efektai + Annotations, form fill-in, and digital signatures Anotacijos, formų pildymas ir skaitmeniniai parašai + Any Bet kuris + Any except extracting pages Bet kas, išskyrus puslapių išskyrimą + Anyone opening the document will need this password. It's applied when you save. Dokumentą atveriant reikės šio slaptažodžio. Jis pritaikomas įrašant dokumentą. + Appear behind page Rodyti už puslapio + Appear on top of page Rodyti virš puslapio + Appearance mode Išvaizdos veiksena + Appearance settings… Išvaizdos nuostatos… + Applies to 1 page Taikoma 1 puslapiui + Applies to {n} pages Taikoma {n} puslapiams + Apply redactions Pašalinti pažymėtą konfidencialią informaciją + Apply scale Taikyti mastelį + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. „GitHub“ tikrinama, ar yra naujausia laida. Nieko automatiškai neatsisiunčiama ir neįdiegiama. + At most Daugiausia + Attachments Priedai + Attachments Panel and Page Priedų skydelis ir puslapis + Audit space usage… Tikrinti vietos naudojimą… + Author Autorius + Auto Automatiškai + Auto follows the system's text size. Pages always render at the display's full resolution. Automatinis režimas naudoja sistemos teksto dydį. Puslapiai visada atvaizduojami visa ekrano raiška. + Auto portrait/landscape Automatiškai parinkti stačią arba gulsčią padėtį + Auto-rotate pages Automatiškai pasukti puslapius + Auto-size text to fit redaction region Automatiškai pritaikyti tekstą šalinimo sričiai + Available Prieinama + Back side only Tik kita pusė + Back to all tools Grįžti į visus įrankius + Back to the document Grįžti į dokumentą + Before each selected page (select pages in Organize) Prieš kiekvieną pasirinktą puslapį (puslapius pasirinkite skiltyje „Tvarkyti“) + Before the number Prieš numerį + Bicubic downsampling to Bikubinis sumažinimas iki + Binding Įrišimas + Binding: Įrišimas: + Black and white Nespalvota + Bold Pusjuodis + Booklet Brošiūra + Booklet subset: Brošiūros dalis: + Bookmark options Žymelės parinktys + Bookmarks Panel and Page Žymelių skydelis ir puslapis + Border Color: Rėmelio spalva: + Borders and Colors Rėmeliai ir spalvos + Both sides Abi pusės + Bottom Apačia + Bottom: Apačia: + Box Stačiakampis + Browser's print dialog Naršyklės spausdinimo dialogas + Butt Plokščias galas + Button is checked by default Mygtukas pažymėtas pagal numatytuosius parametrus + Buttons with the same name and value are selected in unison Mygtukai su tuo pačiu pavadinimu ir reikšme pasirenkami kartu + Bytes Baitai + Calibrate from two points Kalibruoti pagal du taškus + Can't be read: {e} Nepavyksta perskaityti: {e} + Cancelling… Atšaukiama… + Case-sensitive Skiriamos didžiosios ir mažosios raidės + Category: Kategorija: + Center Centruoti + Center window on screen Centruoti langą ekrane + Centre Centras + Certify Patvirtinti + Change settings… Keisti nuostatas… + Change the colour of line art as well as text Keisti ne tik teksto, bet ir linijinių piešinių spalvą + Change {what} Keisti {what} + Changes allowed Leidžiami pakeitimai + Character limit 0 means no limit. A comb of characters needs a limit. Simbolių skaičius 0 reiškia, kad apribojimo nėra. Simbolių tinkleliui apribojimas būtinas. + Character spacing Tarpai tarp simbolių + Check Again Tikrinti dar kartą + Check again Tikrinti dar kartą + Check box is checked by default Žymimasis langelis pažymėtas pagal numatytuosius parametrus + Check for updates Tikrinti, ar yra naujinimų + Check spelling Tikrinti rašybą + Check the pages Tikrinti puslapius + Check the pages of {name}: {why} Tikrinti dokumento {name} puslapius: {why} + Checking Options ({on} of 32 in all categories) Tikrinimo parinktys (visose kategorijose: {on} iš 32) + Checking for a newer version… Tikrinama, ar yra naujesnė versija… + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Pasirinkite PNG arba JPEG paveikslėlį (iki 4 MiB). Skaidrumas išsaugomas. + Choose a folder for the exported files Pasirinkite aplanką eksportuojamiems failams + Choose a folder for the extracted pages Pasirinkite aplanką išskirtiems puslapiams + Choose a folder for the results Pasirinkite aplanką rezultatams + Choose a folder for the searchable files Pasirinkite aplanką failams su ieškomu tekstu + Choose a layout: Pasirinkite išdėstymą: + Choose a stamp, then click on the page to place it. Pasirinkite antspaudą, tada spustelėkite puslapį, kad jį įdėtumėte. + Choose an image Pasirinkite paveikslėlį + Choose images Pasirinkite paveikslėlius + Choose image… Pasirinkite paveikslėlį… + Choose photo… Pasirinkite nuotrauką… + Choose the digital ID that you want to use for signing: Pasirinkite skaitmeninį ID, kurį naudosite pasirašydami: + Circle Apskritimas + Clean Up Išvalyti + Clean-up Valymas + Clear All Išvalyti viską + Clear all Išvalyti viską + Clear selection / close find Panaikinti pasirinkimą / užverti paiešką + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. Spustelėkite puslapį, kad pridėtumėte tekstą; kiekvienas spustelėjimas sukuria naują laukelį, o ✓ užbaigia veiksmą. Vilkite elementus, kad juos perkeltumėte, vilkite kampą, kad pakeistumėte dydį, dukart spustelėkite tekstą, kad jį redaguotumėte. + Click the first point to begin. Spustelėkite pirmąjį tašką, kad pradėtumėte. + Click to sort, drag to move the column. Drag its edge to resize it. Spustelėkite, kad surikiuotumėte; vilkite stulpelį, kad jį perkeltumėte. Vilkite jo kraštą, kad pakeistumėte plotį. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Spustelėkite viršūnes. Paspauskite „Enter“ arba dukart spustelėkite, kad baigtumėte perimetrą ar plotą. Spustelėkite pirmąjį tašką, kad užvertumėte plotą. + Clockwise 90 degrees 90 laipsnių pagal laikrodžio rodyklę + Close (Esc) Užverti (Esc) + Close Tab Užverti kortelę + Close panel Užverti skydelį + Closed arrow Uždara rodyklė + Collapse Suskleisti + Collapse all Suskleisti viską + Collapse all bookmarks Suskleisti visas žymeles + Collapse tag Suskleisti žymą + Collate Rūšiuoti komplektais + Color: Spalva: + Colour Spalva + Colours are remapped while rendering; the document itself never changes. Atvaizduojant spalvos perskaičiuojamos, tačiau pats dokumentas nekeičiamas. + Comb of Simbolių tinklelis: + Combine the rest Sujungti likusius + Combined {n} files Sujungta failų: {n} + Coming in milestone Numatyta būsimame etape + Comment and form data Komentarai ir formų duomenys + Commenting, filling in form fields, and signing existing signature fields Komentavimas, formos laukų pildymas ir esamų parašo laukų pasirašymas + Comments & Forms Komentarai ir formos + Comments only Tik komentarai + Commit selected value immediately Iš karto patvirtinti pasirinktą reikšmę + Common Properties Bendrosios savybės + Compare Files Palyginti failus + Compare this file with an older version: Compare files… in All tools. Palyginkite šį failą su ankstesne versija: įrankyje „Visi įrankiai“ pasirinkite „Palyginti failus…“. + Compatibility Suderinamumas + Compress document structure (object streams) Suglaudinti dokumento struktūrą (objektų srautus) + Compression Glaudinimas + Configure New Digital ID Konfigūruoti naują skaitmeninį ID + Remove "{name}" from this list? The file itself is not deleted. Pašalinti „{name}“ iš šio sąrašo? Pats failas nebus ištrintas. + Confirm Password Patvirtinti slaptažodį + Conformance level: Atitikties lygis: + Continue Tęsti + Copies, two-sided printing and colour are chosen in the browser's print dialog. Kopijų skaičius, dvipusis spausdinimas ir spalvos pasirenkami naršyklės spausdinimo dialoge. + Copies: Kopijų skaičius: + Copy selected text Kopijuoti pažymėtą tekstą + Copy text Kopijuoti tekstą + Couldn't check for updates: {e} Nepavyko patikrinti, ar yra naujinimų: {e} + Couldn't open the printer's preferences: {e} Nepavyko atverti spausdintuvo nuostatų: {e} + Counterclockwise 90 degrees 90 laipsnių prieš laikrodžio rodyklę + Country/Region Šalis / regionas + Create Custom Stamp Kurti pasirinktinį antspaudą + Create Link Kurti saitą + Create PDF Comment Summary Kurti PDF komentarų suvestinę + Create PDF comment summary… Kurti PDF komentarų suvestinę… + Create PDF from images Kurti PDF iš paveikslėlių + Create a new Digital ID (self-signed, saved to a password-protected file) Kurti naują skaitmeninį ID (savarankiškai pasirašytą, įrašomą į slaptažodžiu apsaugotą failą) + Create accessibility report Kurti prieinamumo ataskaitą + Create custom stamp… Kurti pasirinktinį antspaudą… + Create {what} Kurti {what} + Created a PDF from {n} files Iš {n} failų sukurtas PDF + Created a PDF from {n} files; left out: {names} Iš {n} failų sukurtas PDF; neįtraukti: {names} + Creates 1 file from {n} pages. Iš {n} puslapių sukuriamas 1 failas. + Creates {f} files from {n} pages. Iš {n} puslapių sukuriama failų: {f}. + Crop (% trimmed from each side) Apkirpimas (% pašalinama nuo kiekvienos kraštinės) + Currency symbol: Valiutos simbolis: + Current page Dabartinis puslapis + Current page ({n}) Dabartinis puslapis ({n}) + Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Custom Pasirinktinis + Custom scale: Pasirinktinis mastelis: + Custom text: Pasirinktinis tekstas: + Custom: Pasirinktinis: + Cut and stack Spausdinti iškarpomis ir sudėti + Cut marks Pjovimo žymės + DPI sets the printed page size without resampling the image. DPI nustato spausdinamo puslapio dydį nekeičiant paveikslėlio raiškos. + Dark theme Tamsioji tema + Date format Datos formatas + Decimal places: Dešimtainių skilčių skaičius: + Decimals Dešimtainės skiltys + Declared conformance: {shown} Deklaruota atitiktis: {shown} + Decorative figure Dekoratyvinis elementas + Default Numatytasis + Default Value: Numatytoji reikšmė: + Default workspace mode Numatytoji darbo srities veiksena + Default: Numatyta: + Delete page {label} Naikinti puslapį {label} + Delete pages (Delete) Naikinti puslapius („Delete“) + Delete pages after extracting Išskyrus puslapius juos panaikinti + Delete selected pages (Organize) Naikinti pasirinktus puslapius (skiltyje „Tvarkyti“) + Delete stamp Naikinti antspaudą + Delete {o} Naikinti {o} + Describe the figure Aprašykite elementą + Description Aprašas + Description: Aprašas: + Details Išsami informacija + Diamond Rombas + Digital ID Skaitmeninis ID + Digital ID password Skaitmeninio ID slaptažodis + Direction: Kryptis: + Discard Atmesti + Discard Objects Pašalinti objektus + Discard User Data Pašalinti naudotojo duomenis + Discard alternate images Pašalinti alternatyvius paveikslėlius + Discard document tags Pašalinti dokumento žymas + Discard embedded page thumbnails Pašalinti įdėtąsias puslapių miniatiūras + Discard embedded print settings Pašalinti įdėtąsias spausdinimo nuostatas + Discard this text (Esc) Atmesti šį tekstą (Esc) + Dismiss Atmesti + Distinguished name Išskirtinis vardas + Distribute Paskirstyti + Document Dokumentas + Document Properties Dokumento savybės + Document Title Dokumento pavadinimas + Document and markups Dokumentas ir žymos + Document and stamps Dokumentas ir antspaudai + Document language Dokumento kalba + Document properties ({key}) Dokumento savybės ({key}) + Documents and view Dokumentai ir rodinys + Done Atlikta + Done adding text Tekstas pridėtas + Download Atsisiųsti + Downsample to Sumažinti raišką iki + Drag the selection to move it. Esc clears the selection. Vilkite pažymėtą objektą, kad jį perkeltumėte. Paspaudus „Esc“ pasirinkimas panaikinamas. + Drag to reorder Vilkite, kad pakeistumėte tvarką + Draw Brėžti + Draw your {what} below. Žemiau nupieškite {what}. + Drawing scale changes. Use Actual size for full-scale plans. Brėžinio mastelis keičiasi. Tikram masteliui pasirinkite „Faktinis dydis“. + Drop PDFs, images, text files or folders here, or choose them. Įkelkite PDF, paveikslėlius, tekstinius failus ar aplankus čia arba pasirinkite juos. + Duplicate "{name}" onto: Dubliuoti „{name}“ į: + Duplicate Field Dubliuoti lauką + Duplicate… Dubliuoti… + Dynamic Dinaminė + Dynamic XFA form laid out from its template: {pages}, {fields}. Pagal šabloną išdėstyta dinaminė XFA forma: {pages}, {fields}. + Each file holds as many of the {n} pages as fit. Kiekviename faile telpa tiek iš {n} puslapių, kiek leidžia dydis. + Each image is measured where pages draw it; an image is replaced only if the result is smaller. Kiekvienas paveikslėlis vertinamas pagal jo atvaizduojamą dydį puslapyje; pakeičiamas tik tada, jei rezultatas mažesnis. + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. Kiekvienas paveikslėlis įrašomas vieną kartą, pavadinant pagal dokumentą ir puslapį. JPEG paveikslėliai paliekami nepakeisti, kiti įrašomi PNG formatu. + Earlier in tab order Ankstesnė skirtukų tvarka + Edit Action Redaguoti veiksmą + Edit image Redaguoti paveikslėlį + Edit text… Redaguoti tekstą… + Edit… Redaguoti… + Email Address El. pašto adresas + Enable Acrobat JavaScript Įjungti „Acrobat JavaScript“ + Enable copying of text, images, and other content Leisti kopijuoti tekstą, paveikslėlius ir kitą turinį + Enable text access for screen reader devices for the visually impaired Leisti regos negalią turintiems žmonėms skirtoms ekrano skaitymo programoms pasiekti tekstą + Encrypt Šifruoti + Endpoints Galiniai taškai + Enhance page Pagerinti puslapį + Enter the password that opens it Įveskite jį atveriantį slaptažodį + Enter the permissions password to allow combining Įveskite teisių slaptažodį, kad galėtumėte sujungti + Entire page Visas puslapis + Even Pages Only Tik lyginiai puslapiai + Even and Odd Pages Lyginiai ir nelyginiai puslapiai + Even pages only Tik lyginiai puslapiai + Every PDF, image and text file in the folder Visi aplanke esantys PDF, paveikslėlių ir tekstiniai failai + Every PDF, image and text file in the folder and the folders inside it Visi aplanke ir jo poaplankiuose esantys PDF, paveikslėlių ir tekstiniai failai + Example: {sample} Pavyzdys: {sample} + Exclude images smaller than Neįtraukti mažesnių paveikslėlių nei + Expand Išskleisti + Expand all Išskleisti viską + Expand all bookmarks Išskleisti visas žymeles + Expand tag Išskleisti žymą + Expand top-level bookmarks Išskleisti aukščiausio lygio žymeles + Explain Paaiškinti + Export All Images Eksportuoti visus paveikslėlius + Export Value: {on} Eksportuoti reikšmę: {on} + Export all to data file… Eksportuoti viską į duomenų failą… + Export certificate… Eksportuoti sertifikatą… + Export comments Eksportuoti komentarus + Export form data Eksportuoti formos duomenis + Export stopped: {e} Eksportavimas sustabdytas: {e} + Export to Image Eksportuoti kaip paveikslėlį + Export to Text Eksportuoti kaip tekstą + Exported 1 image Eksportuotas 1 paveikslėlis + Exported the text of 1 page Eksportuotas 1 puslapio tekstas + Exported the text of {n} pages Eksportuotas {n} puslapių tekstas + Exported {n} images Eksportuota paveikslėlių: {n} + Exporting… {d} of {t} Eksportuojama… {d} iš {t} + Export… Eksportuoti… + Extract pages as separate files Išskirti puslapius į atskirus failus + Extract pages to a new document Išskirti puslapius į naują dokumentą + Extracted 1 page Išskirtas 1 puslapis + Extracted {n} pages Išskirta puslapių: {n} + Field is used for file selection Laukas skirtas failui pasirinkti + Field names (comma-separated) Laukų pavadinimai (atskirti kableliais) + Field names with + - * / and parentheses; put \\ before spaces in names. Laukų pavadinimai su + - * / ir skliaustais; prieš tarpą pavadinime rašykite \\. + Field value is in range: Lauko reikšmė patenka į intervalą: + Field value is not validated Lauko reikšmė netikrinama + Field: {field} on page {p} Laukas {field}, puslapis {p} + Fields to reset (comma-separated; empty: all) Atkuriami laukai (atskirti kableliais; tuščia reikšmė – visi) + Figure {i} of {n}{page} Paveikslas {i} iš {n}{page} + File (.p12, .pfx) Failas (.p12, .pfx) + File Name Failo pavadinimas + File size Failo dydis + Files are combined from top to bottom. Leave Pages empty to take every page. Failai jungiami iš viršaus į apačią. Palikite lauką „Puslapiai“ tuščią, jei norite įtraukti visus puslapius. + Files are combined in the order shown. Drag them to change it. Failai sujungiami rodoma tvarka. Vilkite juos, kad ją pakeistumėte. + Files for {name} Failai: {name} + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Čia rodomi „PdfCraft“ atverti failai. Įkelkite PDF bet kurioje vietoje, kad jį atvertumėte. + Fill & Sign dates Datos pildymo ir pasirašymo įrankyje + Fill & Sign signature Parašas pildymo ir pasirašymo įrankyje + Fill Color: Užpildo spalva: + Fill colour Užpildo spalva + Fill colour: none Užpildo spalva: nėra + Filling in form fields and signing existing signature fields Formos laukų pildymas ir esamų parašo laukų pasirašymas + Filter comments Filtruoti komentarus + Find a tag Rasti žymą + Find options Paieškos parinktys + Find text Ieškoti teksto + Find tools and commands Ieškoti įrankių ir komandų + Finding document fonts… Ieškoma dokumento šriftų… + Finish or discard the text being edited before selecting several objects. Prieš pasirinkdami kelis objektus užbaikite redaguoti tekstą arba atmeskite pakeitimus. + First / last page Pirmas / paskutinis puslapis + Fit Pritaikyti + Fit Height Pritaikyti aukštį + Fit Page Pritaikyti puslapiui + Fit Visible Pritaikyti matomai sričiai + Fit Width Pritaikyti plotį + Fit to height Pritaikyti aukščiui + Fit to page Pritaikyti puslapiui + Fix Taisyti + Fixed what could be fixed; 1 problem remains Ištaisyta tai, ką buvo galima; liko 1 problema + Fixed what could be fixed; {n} problems remain Ištaisyta tai, ką buvo galima; liko problemų: {n} + Flatten Fill & Sign when saving Įrašant suplokštinti pildymo ir pasirašymo turinį + Flip Horizontal Apversti horizontaliai + Flip Vertical Apversti vertikaliai + Flip horizontal Apversti horizontaliai + Flip on long edge Apversti per ilgąją kraštinę + Flip on short edge Apversti per trumpąją kraštinę + Flip vertical Apversti vertikaliai + Folders Aplankai + Font Size: Šrifto dydis: + Font colour: Šrifto spalva: + Font size 0 uses automatic sizing. Šrifto dydis 0 reiškia, kad dydis parenkamas automatiškai. + Font size: Šrifto dydis: + Font: Šriftas: + Font: Helvetica Šriftas: „Helvetica“ + Fonts Šriftai + For page sizes and margins. Puslapių dydžiams ir paraštėms. + Form data and PDF forms Formų duomenys ir PDF formos + Form fields are flattened: their values stay visible. Saving rewrites the whole file. Formos laukai suplokštinami: jų reikšmės lieka matomos. Įrašant perrašomas visas failas. + Form fields only Tik formos laukai + Form fill-in and digital signatures Formų pildymas ir skaitmeniniai parašai + Format Formatas + Format text Formatuoti tekstą + Format: Formatas: + From Nuo + From a PDF page or an image Iš PDF puslapio arba paveikslėlio + From colour Iš spalvos + From {file} Iš {file} + From: Nuo: + Front side only Tik priekinė pusė + Get help, share feedback and follow development on Discord. Gaukite pagalbos, pateikite atsiliepimų ir sekite naujienas „Discord“. + Go to a page view Pereiti į puslapio rodinį + Go to page {p} Pereiti į puslapį {p} + Go to the page Pereiti į puslapį + Go to this result Pereiti prie šio rezultato + Graphic Grafika + Greyscale Pilki atspalviai + Grid view Tinklelio rodinys + Hand (H) Rankos įrankis (H) + Has JavaScript actions (run in M6) Turi JavaScript veiksmų (bus vykdomi M6 etape) + Height: Aukštis: + Hide field highlights Slėpti laukų paryškinimą + Hide layer Slėpti sluoksnį + Hide menu bar Slėpti meniu juostą + Hide password Slėpti slaptažodį + Hide toolbars Slėpti įrankių juostas + Hide window controls Slėpti lango valdiklius + High Resolution Didelė raiška + High resolution Didelė raiška + Highlight Style: Paryškinimo stilius: + Highlight fields Paryškinti laukus + Home Pradžia + Horizontal Horizontaliai + Horizontal reversed Horizontaliai atvirkščiai + Horizontal scale Horizontalusis mastelis + Icon Piktograma + Images and text Paveikslėliai ir tekstas + Images and text (converted to PDF) Paveikslėliai ir tekstas (konvertuoti į PDF) + Import list… Importuoti sąrašą… + Includes unsaved changes Yra neįrašytų pakeitimų + Indent Įtrauka + Initial View Pradinis rodinys + Ink sensitivity Rašiklio jautrumas + Insert Date Įterpti datą + Insert Page Number Įterpti puslapio numerį + Insert a blank page after the selection Po pasirinkimo įterpti tuščią puslapį + Insert a file at the end Failą įterpti pabaigoje + Insert a file before page {label} Failą įterpti prieš puslapį {label} + Insert pages from a file… Įterpti puslapius iš failo… + Inserting, deleting, and rotating pages Puslapių įterpimas, naikinimas ir pasukimas + Installed fonts… Įdiegti šriftai… + Intersections Sankirtos + Invisible Rectangle Nematomos stačiakampės srities anotacija + Issued by: Išdavė: + Italic Kursyvas + Item List Elementų sąrašas + Item: Elementas: + Items marked Ready work today. Elementai, pažymėti „Parengta“, veikia dabar. + Its security settings don't allow copying pages Saugos nuostatos neleidžia kopijuoti puslapių + JavaScript Console JavaScript konsolė + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript išjungtas (Nuostatos > JavaScript). + JavaScript, e.g. getField("total").value JavaScript, pvz., getField("total").value + Join our Discord Prisijunkite prie mūsų „Discord“ serverio + Join the ArtCraft community Prisijunkite prie „ArtCraft“ bendruomenės + Justify Abipusė lygiuotė + Keep source fonts Išsaugoti šaltinio šriftus + Keep tool selected Palikti įrankį pasirinktą + Keep tool selected: on Palikti įrankį pasirinktą: įjungta + Key Algorithm Rakto algoritmas + Keychain · Raktinė · + Label Žyma + Labels Žymos + Labels: {preview}. Later pages keep their labels. Žymos: {preview}. Tolesniuose puslapiuose žymos išlieka. + Landscape Gulsčias + Landscape Pages Gulsčios orientacijos puslapiai + Landscape and Portrait Pages Gulsčios ir stačios orientacijos puslapiai + Larger pages Didesni puslapiai + Later Vėliau + Later in tab order Vėlesnė skirtukų tvarka + Layers Sluoksniai + Layers Panel and Page Sluoksnių skydelis ir puslapis + Layout and Magnification Išdėstymas ir mastelis + Leave out files that can't be read? Neįtraukti failų, kurių nepavyksta perskaityti? + Left Kairė + Left Edge Kairysis kraštas + Left out: {names} Neįtraukti: {names} + Left: Kairėje: + Light theme Šviesioji tema + Limit of Riba: + Line Style: Linijos stilius: + Line Thickness: Linijos storis: + Line ending Linijos galas + Line spacing Eilučių intervalas + Line thickness Linijos storis + Link Action Saito veiksmas + Link Appearance Saito išvaizda + Link Properties Saito savybės + Link Type: Saito tipas: + List view Sąrašo rodinys + Load font… Įkelti šriftą… + Locked Užrakinta + Looking for PDFs… Ieškoma PDF failų… + Looking for printers… Ieškoma spausdintuvų… + Low Resolution (150 dpi) Maža raiška (150 dpi) + Low resolution Maža raiška + Magnification Didinimas + Make Current Properties Default Dabartines savybes nustatyti kaip numatytąsias + Margins Paraštės + Mark Page Range Pažymėti puslapių intervalą + Mark as comments Pažymėti kaip komentarus + Mark with checkmark Pažymėti varnele + Marked Pažymėta + Mask: Kaukė: + Match page at 100% Sutapatinti puslapį esant 100 % masteliui + Medium Vidutinė + Midpoints Vidurio taškai + Mixed Mišrios reikšmės + Modified Pakeista + More options Daugiau parinkčių + More options: Daugiau parinkčių: + More ways to add files Daugiau būdų pridėti failų + Mouse wheel scroll speed Slinkimo ratuku greitis + Move down Perkelti žemyn + Move earlier Perkelti anksčiau + Move later Perkelti vėliau + Move up Perkelti aukštyn + Multi-line Kelių eilučių + Multiple Keli + Multiple selection Kelių elementų pasirinkimas + Multiple words or phrases Keli žodžiai ar frazės + Name: Pavadinimas: + Navigation tab Naršymo kortelė + Needs manual check Reikia patikrinti rankiniu būdu + Negative number style: Neigiamų skaičių formatas: + New Action Naujas veiksmas + New Action… Naujas veiksmas… + New bookmark ({key}) Nauja žymelė ({key}) + New file: Naujas failas: + Next ({key}) Kitas ({key}) + Next / previous match Kitas / ankstesnis atitikmuo + Next figure Kitas paveikslas + Next month Kitas mėnuo + Next page Kitas puslapis + Next sheet Kitas lapas + Next view Kitas rodinys + No PDFs in this folder yet Šiame aplanke PDF failų dar nėra + No actions Veiksmų nėra + No changes allowed Pakeitimai neleidžiami + No changes to save Nėra įrašytinų pakeitimų + No check has run yet. Patikra dar nevykdyta. + No color Be spalvos + No colour Be spalvos + No comments match. Atitinkančių komentarų nėra. + No comments yet. Komentarų dar nėra. + No differences in the text. Tekste skirtumų nėra. + No digital IDs yet. Skaitmeninių ID dar nėra. + No documents are open Nėra atvertų dokumentų + No file chosen Failas nepasirinktas + No fill Be užpildo + No fonts are referenced by the pages. Puslapiuose nenaudojami jokie šriftai. + No issues found Problemų nerasta + No limit Neribojama + No matches Atitikmenų nėra + No matches. Atitikmenų nėra. + No matching tools Atitinkančių įrankių nėra + No openly licensed document fonts found. Load a font file. Nerasta atvirąją licenciją turinčių dokumento šriftų. Įkelkite šrifto failą. + No pages selected Nepasirinkta puslapių + No photo chosen Nuotrauka nepasirinkta + No preview Peržiūros nėra + No problems found: the document conforms to {level}. Problemų nerasta: dokumentas atitinka {level} reikalavimus. + No security Apsaugos nėra + No sheets Lapų nėra + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Rėmelyje parašo nerasta. Perkelkite rėmelį ant parašo arba padidinkite rašalo jautrumą. + No status has been set. Būsena nenustatyta. + None (prefix only) Nėra (tik priešdėlis) + None found Nieko nerasta + None — everything is allowed Nėra — viskas leidžiama + Not allowed Neleidžiama + Not present Nėra + Nothing to redo Nėra ką pakartoti + Nothing to undo Nėra ko anuliuoti + Number Skaičius + Number of pages Puslapių skaičius + Number pages Numeruoti puslapius + Odd Pages Only Tik nelyginiai puslapiai + Odd pages only Tik nelyginiai puslapiai + Offset from centre Poslinkis nuo centro + Old file: Senas failas: + One word or phrase per line Po vieną žodį ar frazę eilutėje + One {f} file per page, named after the document. Po vieną {f} failą kiekvienam puslapiui, pavadintą pagal dokumentą. + Only change the colour of black text or line art Keisti tik juodo teksto ir linijinių piešinių spalvą + Only checked properties will change. Mixed means the selected fields have different values. Bus pakeistos tik pažymėtos savybės. „Mišrios reikšmės“ reiškia, kad pasirinktų laukų reikšmės skiriasi. + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. Keičiamas tik puslapio turinys: saitai, komentarai, formos laukai ir žymelės pradiniuose puslapiuose lieka. + Open Atverti + Open a PDF ({key}) Atverti PDF ({key}) + Open a PDF to measure it. Atverkite PDF, kad galėtumėte jį išmatuoti. + Open a document first Pirmiausia atverkite dokumentą + Open a web page Atverti tinklalapį + Open arrow Atvira rodyklė + Open email app Atverti el. pašto programą + Open in Full Screen mode Atverti viso ekrano veiksena + Open in a new tab Atverti naujoje kortelėje + Open link Atverti saitą + Open the file as it was saved then Atverti anksčiau įrašytą failo versiją + Open the older version first Pirmiausia atverkite senesnę versiją + Open this web page? Atverti šį tinklalapį? + Open to page Atverti ties puslapiu + Opened, but its security settings don't allow combining. Enter the permissions password. Atverta, tačiau saugos nuostatos neleidžia sujungti. Įveskite teisių slaptažodį. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Atveriamos spausdintuvo tvarkyklės nuostatos. Jos įrašomos visiems šiuo kompiuteriu atliekamiems spausdinimams; čia pasirinktas kopijų skaičius, dvipusis spausdinimas, pilki atspalviai ir popierius lieka taikomi. + Optimizing… cleaning up Optimizuojama… tvarkoma + Optimizing… image {d} of {t} Optimizuojama… paveikslėlis {d} iš {t} + Optimizing… merging identical objects Optimizuojama… jungiami vienodi objektai + Optimizing… removing user data Optimizuojama… šalinami naudotojo duomenys + Optimizing… writing the copy Optimizuojama… įrašoma kopija + Optional: needs an AI provider you configure Pasirenkama: reikia sukonfigūruoti DI teikėją + Options… Parinktys… + Organization Name Organizacijos pavadinimas + Organizational Unit Organizacijos padalinys + Orientation: Padėtis: + Original Pradinis + Original: replace pages Pradinis dokumentas: pakeisti puslapius + Other colour… Kita spalva… + Outdent Sumažinti įtrauką + Output Išvestis + Output appears here. Čia bus rodoma išvestis. + Output intent: {list} Išvesties paskirtis: {list} + Overlap: Persidengimas: + Owner password (no restrictions apply) Savininko slaptažodis (apribojimai netaikomi) + PDF Optimizer PDF optimizatorius + PDF or image PDF arba paveikslėlis + PDF page: {size} PDF puslapis: {size} + PDF scale: {scale}% PDF mastelis: {scale} % + PDF, images and text PDF, paveikslėliai ir tekstas + Page Only Tik puslapis + Page Range Options Puslapių intervalo parinktys + Page Sizing & Handling Puslapio dydis ir apdorojimas + Page background Puslapio fonas + Page display: {layout} Puslapių rodinys: {layout} + Page layout Puslapio išdėstymas + Page navigation Naršymas puslapiuose + Page number Puslapio numeris + Page order: Puslapių tvarka: + Page size Puslapio dydis + Page {label} Puslapis {label} + Page {p} of {n} Puslapis {p} iš {n} + Pages Panel and Page Puslapių skydelis ir puslapis + Pages from Puslapiai nuo + Pages of {name} to combine, e.g. 1-3, 6 Jungiami dokumento {name} puslapiai, pvz., 1–3, 6 + Pages per file Puslapių faile + Pages per sheet: Puslapių lape: + Pages to Print Spausdinami puslapiai + Pages: Puslapiai: + Pages: {k} of {n} Puslapiai: {k} iš {n} + Paper: Popierius: + Part of Dalis iš + Password Slaptažodis + Password required Reikalingas slaptažodis + Password security Apsauga slaptažodžiu + Password-protected Apsaugotas slaptažodžiu + Paste after Įdėti po + Paths Keliai + Pattern Šablonas + Patterns Šablonai + Patterns (graphics objects) Šablonai (grafiniai objektai) + PdfCraft didn't shut down normally. These documents had changes that were autosaved: „PdfCraft“ buvo uždaryta netinkamai. Šių dokumentų pakeitimai buvo automatiškai įrašyti: + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. „PdfCraft“ veikia neprisijungus. Nerenkami telemetrijos duomenys, nereikia paskyros ir nenaudojamos debesijos paslaugos, nebent pridėtumėte jų teikėją. + PdfCraft {c} is up to date. Naudojate naujausią „PdfCraft“ versiją {c}. + PdfCraft {v} is available. Išleista „PdfCraft“ versija {v}. + Percentage Procentai + Permissions password Teisių slaptažodis + Permitted actions after certifying Po patvirtinimo leidžiami veiksmai + Photo Nuotrauka + Pick a tool in the quick bar, or type above. Pasirinkite įrankį sparčiojoje juostoje arba įveskite paieškos tekstą aukščiau. + Pictures on those pages are recognized too; words already in the page's text are not added again Paveikslėliuose esantis tekstas taip pat atpažįstamas; puslapio tekste jau esantys žodžiai dar kartą nepridedami + Pin a folder to Home Prisegti aplanką pradžios lange + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Prisekite aplanką, kad čia būtų rodomi naujausi PDF failai, pavyzdžiui, nuskaityti ar sinchronizuotoje debesijos saugykloje įrašyti dokumentai. + Pin a folder… Prisegti aplanką… + Plain text in reading order; pages are separated by form feeds. Paprastasis tekstas skaitymo tvarka; puslapiai atskiriami puslapio skirtukais. + Planned Suplanuota + Planned for milestone Numatyta etapui + Points from the page's bottom-left corner. Taškai skaičiuojami nuo puslapio apatinio kairiojo kampo. + Portrait Stačias + Portrait Pages Stačios orientacijos puslapiai + Post Skelbti + Poster Plakatas + Prefix Priešdėlis + Preview Peržiūra + Preview unavailable: {e} Peržiūra negalima: {e} + Preview: page {p} of {n} Peržiūra: {p} puslapis iš {n} + Previous ({key}) Ankstesnis ({key}) + Previous / next page Ankstesnis / kitas puslapis + Previous figure Ankstesnis paveikslas + Previous month Ankstesnis mėnuo + Previous page Ankstesnis puslapis + Previous sheet Ankstesnis lapas + Previous view Ankstesnis rodinys + Print ({key}) Spausdinti ({key}) + Print in grayscale Spausdinti pilkais atspalviais + Print page border Spausdinti puslapio rėmelį + Print, NextPage, PrevPage, FirstPage or LastPage Print, NextPage, PrevPage, FirstPage arba LastPage + Printer paper and scaling may differ. Check driver settings. Spausdintuvo popieriaus formatas ir mastelis gali skirtis. Patikrinkite tvarkyklės nuostatas. + Printer properties Spausdintuvo savybės + Printer: Spausdintuvas: + Printing allowed Leidžiama spausdinti + Privacy Privatumas + Properties… Savybės… + Protect Using Password Apsaugoti slaptažodžiu + Protected: the combined file won't keep its security settings Apsaugota: sujungtame faile saugos nuostatos neišliks + Quality Kokybė + Read Only Tik skaityti + Reading colours Skaitomos spalvos + Ready Parengta + Reason Priežastis + Recent Paskiausiai naudoti + Recent colours Paskiausiai naudotos spalvos + Recognize Text Atpažinti tekstą + Recognized 1 word Atpažintas 1 žodis + Recognized {n} words Atpažinta žodžių: {n} + Recognized {w} words in 1 file 1 faile atpažinta žodžių: {w} + Recognized {w} words in {n} files {n} failuose atpažinta žodžių: {w} + Recognizing text… file {d} of {t} Atpažįstamas tekstas… failas {d} iš {t} + Recognizing text… page {d} of {t} Atpažįstamas tekstas… puslapis {d} iš {t} + Recommended tools Rekomenduojami įrankiai + Recover Atkurti + Recover unsaved documents? Atkurti neįrašytus dokumentus? + Redact all Pašalinti visą konfidencialią informaciją + Redacted area fill colour: Šalinamos srities užpildo spalva: + Redaction Tool Properties Konfidencialios informacijos šalinimo įrankio savybės + Redaction code: Šalinimo kodas: + Reload Įkelti iš naujo + Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske + Remove checkmark Pašalinti varnelę + Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles + Remove page Pašalinti puslapį + Remove unreferenced named destinations Pašalinti nenaudojamus pavadintus paskirties taškus + Remove unused objects and merge identical ones Pašalinti nenaudojamus objektus ir sujungti vienodus + Remove {name} Pašalinti {name} + Remove {name} from Recent Pašalinti {name} iš paskiausiai naudotų failų + Remove {n} files Pašalinti failų: {n} + Remove {n} pages Pašalinti puslapių: {n} + Reopen Closed Tab Vėl atverti užvertą kortelę + Reopen the files that were open when PdfCraft last closed Vėl atverti failus, kurie buvo atverti paskutinį kartą užveriant „PdfCraft“ + Repair log Taisymo žurnalas + Repeat overlay text Kartoti perdangos tekstą + Replace Image… Pakeisti paveikslėlį… + Replace Pages Pakeisti puslapius + Replace document colours Pakeisti dokumento spalvas + Replace image Pakeisti paveikslėlį + Replacement text Pakaitinis tekstas + Replacement: pages of {name} Pakaitinis dokumentas: {name} puslapiai + Replaces the existing one on those pages. Tuose puslapiuose pakeičia esamą. + Report Ataskaita + Report… Ataskaita… + Requires user to enter a password for: Naudotojas turi įvesti slaptažodį, kad galėtų: + Reset columns Atkurti stulpelius + Reset page size Atkurti puslapio dydį + Reset to printer defaults Atkurti numatytąsias spausdintuvo nuostatas + Resize window to initial page Pakeisti lango dydį, kad tilptų pradinis puslapis + Resolution Raiška + Retain existing Palikti esamą + Reveal in File Explorer Rodyti „File Explorer“ + Reveal in Finder Rodyti „Finder“ + Reverse closed arrow Atvirkštinė uždara rodyklė + Reverse open arrow Atvirkštinė atvira rodyklė + Reverse pages Apversti puslapius + Revert changes Atšaukti pakeitimus + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Grąžinti anksčiau įrašytą „{name}“ versiją? Po to atliktų pakeitimų nebegalėsite atkurti. + Review History Pakeitimų retrospektyva + Revisions Versijos + Right Dešinė + Right Edge Dešinysis kraštas + Rotate Clockwise Pasukti pagal laikrodžio rodyklę + Rotate Counterclockwise Pasukti prieš laikrodžio rodyklę + Rotate Pages Pasukti puslapius + Rotate clockwise Pasukti pagal laikrodžio rodyklę + Rotate counterclockwise Pasukti prieš laikrodžio rodyklę + Rotate view Pasukti rodinį + Rotate view clockwise Pasukti rodinį pagal laikrodžio rodyklę + Rotate view clockwise ({key}) Pasukti rodinį pagal laikrodžio rodyklę ({key}) + Rotate view counterclockwise Pasukti rodinį prieš laikrodžio rodyklę + Rotate: Pasukimas: + Rotation: Pasukimas: + Running action… file {d} of {t} Vykdomas veiksmas… failas {d} iš {t} + Same as interface ({language}) Kaip sąsajoje ({language}) + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. Išvalant dokumentą pašalinama paslėpta informacija: metaduomenys, failų priedai, komentarai, formos laukai (suplokštinami), paslėptas tekstas ir sluoksniai, žymelės, saitai, veiksmai, scenarijai ir privatūs programos duomenys. + Save & Close Įrašyti ir užverti + Save ({key}) Įrašyti ({key}) + Save Image As… Įrašyti paveikslėlį kaip… + Copy Image Kopijuoti paveikslėlį + The image has been copied ({w} × {h} pixels) Paveikslėlis nukopijuotas ({w} × {h} pikselių) + Couldn't copy the image: {e} Nepavyko nukopijuoti paveikslėlio: {e} + Copy Image in Original Format Kopijuoti paveikslėlį originaliu formatu + The image has been copied as a {ext} file Paveikslėlis nukopijuotas kaip {ext} failas + The page has changed; select the image again Puslapis pasikeitė; pažymėkite paveikslėlį iš naujo + Save as PDF Įrašyti kaip PDF + Save attachment… Įrašyti priedą… + Save changes to “{name}” before closing? Prieš užveriant įrašyti „{name}“ pakeitimus? + Save pages Įrašyti puslapius + Save tag properties Įrašyti žymos savybes + Save the accessibility report Įrašyti prieinamumo ataskaitą + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. Po to įrašykite dokumentą: įrašant perrašomas visas failas, kad neliktų pašalinto turinio pėdsakų. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. Po to įrašykite dokumentą; įrašant perrašomas visas failas, kad neliktų pašalinto turinio. + Save the spreadsheet Įrašyti skaičiuoklę + Save these pages as one PDF Įrašyti šiuos puslapius kaip vieną PDF + Saved measurements Įrašyti matavimai + Saved measurements. Matavimai įrašyti. + Scale relative to target page Mastelis pagal paskirties puslapį + Scan Nuskaityti + Script Name: Scenarijaus pavadinimas: + Script: Scenarijus: + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Scenarijai vykdomi izoliuotoje aplinkoje be prieigos prie failų ir tinklo. Scenarijui paprašius atverti tinklalapį, pirmiausia reikės jūsų leidimo. Išjungus JavaScript, vis tiek veikia standartinės „Acrobat“ formatavimo, tikrinimo ir skaičiavimo funkcijos. + Scroll long text Slinkti ilgą tekstą + Scroll tabs left Slinkti korteles kairėn + Scroll tabs right Slinkti korteles dešinėn + Search comments Ieškoti komentaruose + Search tools and commands… Ieškoti įrankių ir komandų… + Searchable Image (Exact) Paveikslėlis su tiksliu atpažinimu + Searching… {s} of {p} pages Ieškoma… {s} iš {p} puslapių + Searching… {s}/{p} Ieškoma… {s}/{p} + Security Sauga + Security settings Saugos nuostatos + Select (V) Pasirinkti (V) + Select Action: Pasirinkite veiksmą: + Select All Pažymėti viską + Select Icon Pasirinkti piktogramą + Select Trigger: Pasirinkite paleidiklį: + Select a file first Pirmiausia pasirinkite failą + Select a file for the stamp Pasirinkite failą antspaudui + Select a password-protected file first Pirmiausia pasirinkite slaptažodžiu apsaugotą failą + Select a word Pasirinkti žodį + Select all pages (Organize) Pasirinkti visus puslapius (skiltyje „Tvarkyti“) + Select at most 1000 objects at a time. Vienu metu pasirinkite ne daugiau kaip 1 000 objektų. + Select data files to merge Pasirinkite jungiamus duomenų failus + Select format category: Pasirinkite formato kategoriją: + Select page {label} Pasirinkti puslapį {label} + Select pages Pasirinkti puslapius + Select the items to remove from this document. Pasirinkite iš dokumento šalinamus elementus. + Selected image files: {count} Pasirinkti paveikslėlių failai: {count} + Selected pages Pasirinkti puslapiai + Selection Pasirinkimas + Sending to {printer}… Siunčiama į {printer}… + Sensitivity Jautrumas + Sent to {printer} Išsiųsta į {printer} + Sent to {printer}: {msg} Išsiųsta į {printer}: {msg} + Separator style: Skirtuko stilius: + Set Alternate Text Nustatyti alternatyvųjį tekstą + Set Fields to Same Size Nustatyti vienodą laukų dydį + Set Page Boxes Nustatyti puslapio sritis + Set layer visibility Nustatyti sluoksnio matomumą + Set status Nustatyti būseną + Set to current page ({n}) Nustatyti į dabartinį puslapį ({n}) + Set to zero Nustatyti į nulį + Shared Field Properties Bendrosios laukų savybės + Sheet {s} of {n} {s} lapas iš {n} + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. „Shift“ apriboja kampą iki 45 laipsnių. „Backspace“ pašalina viršūnę. „Escape“ atšaukia veiksmą. + Shift- or Ctrl/Command-click to select several fields. Norėdami pasirinkti kelis laukus, spustelėkite laikydami „Shift“ arba „Ctrl“ / „Command“. + Shift-click to select several objects. Drag empty space to select an area. Norėdami pasirinkti kelis objektus, spustelėkite laikydami „Shift“. Vilkite tuščioje vietoje, kad pažymėtumėte sritį. + Show Report Rodyti ataskaitą + Show all Rodyti viską + Show all comments Rodyti visus komentarus + Show certificate… Rodyti sertifikatą… + Show layer Rodyti sluoksnį + Show less Rodyti mažiau + Show password Rodyti slaptažodį + Show the accessibility report Rodyti prieinamumo ataskaitą + Show this dialog when the Checker starts Paleidus tikrintuvą rodyti šį dialogą + Show {n} more Rodyti dar {n} + Show: Rodyti: + Shrink oversized pages Sumažinti per didelius puslapius + Sign Here Pasirašykite čia + Sign this field… Pasirašyti šiame lauke… + Sign yourself Pasirašyti pačiam + Signature Details Parašo informacija + Signature is INVALID: Parašas NETEISINGAS: + Signature is valid: Parašas galioja: + Signature panel Parašų skydelis + Signature validity is UNKNOWN: Parašo galiojimas NEŽINOMAS: + Signer's name Pasirašiusiojo vardas + Simplified field notation: Supaprastinta lauko išraiška: + Single Page Vienas puslapis + Single Page Continuous Vienas puslapis, nepertraukiamas rodinys + Single word or phrase Vienas žodis ar frazė + Skip Rule Praleisti taisyklę + Skipped Praleista + Slash Pasvirasis brūkšnys + Smaller pages Mažesni puslapiai + Snap to drawing Pritraukti prie brėžinio + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Kai kurios žymelės nerodomos: skydelyje pateikiamas ribotas jų skaičius ir įdėjimo lygis. + Some page content couldn't be read; snapping uses the rest. Nepavyko perskaityti dalies puslapio turinio; pritraukimui naudojama likusi dalis. + Sort by Rūšiuoti pagal + Sort comments by: Rūšiuoti komentarus pagal: + Sort items Rūšiuoti elementus + Source Šaltinis + Space Audit Vietos naudojimo analizė + Special Specialusis + Split document Skaidyti dokumentą + Split into files… Skaidyti į failus… + Square Kvadratas + Standard Business Standartinis verslo formatas + Start Pradėti + Start Checking Pradėti tikrinimą + Start page number Pradinio puslapio numeris + Status Būsena + Steps Veiksmai + Strength: {s} Stiprumas: {s} + Style Stilius + Subject Tema + Subset Poaibis + Summarize Options Apibendrinimo parinktys + Summary Suvestinė + Switch to List view to change the pages it takes. Norėdami pakeisti pasirinktus puslapius, perjunkite sąrašo rodinį. + Switch to two-page view first to show the cover page Norėdami rodyti viršelio puslapį, pirmiausia perjunkite dviejų puslapių rodinį + Tab order: move a field with its arrows. Skirtukų tvarka: lauką perkelkite rodyklėmis. + Tag type Žymos tipas + Tags Žymos + Text Color: Teksto spalva: + Text alignment: Teksto lygiuotė: + Text in images isn't found; recognise text (OCR) first. Paveikslėliuose esantis tekstas nerandamas; pirmiausia atpažinkite tekstą (OCR). + Text is compared word by word; the differences are shown on the new file. Tekstas lyginamas žodis po žodžio; skirtumai rodomi naujame faile. + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Teksto atpažinimas neįdiegtas: trūksta modelio failų. Iš naujo įdiekite „PdfCraft“ arba nustatykite PDFCRAFT_MODELS aplanką, kuriame jie laikomi. + Text to find Ieškomas tekstas + Text to insert Įterpiamas tekstas + Text used as a clipping path cannot be moved with this selection. Teksto, naudojamo kaip iškarpymo kelias, negalima perkelti pasirinkus šį objektą. + Text, marks and signatures become part of the page. Other comments stay editable. Tekstas, žymos ir parašai tampa puslapio dalimi. Kitus komentarus galima redaguoti. + Thai and Arabic use regular fallback fonts when available. Tajų ir arabų kalboms naudojami įprasti atsarginiai šriftai, jei jie prieinami. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF teksto šriftai negali atvaizduoti simbolio „{c}“ ({code}). Pasirinkite palaikomą tekstą. + The checker found 1 problem which may prevent the document from being fully accessible. Tikrintuvas aptiko 1 problemą, dėl kurios dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The checker found no problems in this document. Tikrintuvas šiame dokumente problemų nerado. + The checker found {n} problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų: {n}. Dėl jų dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The document now conforms to {level}; save it to keep the changes Dokumentas dabar atitinka {level}; įrašykite jį, kad išsaugotumėte pakeitimus + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. Dokumentą galima atverti be slaptažodžio; šis slaptažodis reikalingas apribojimams keisti. Jis pritaikomas įrašant dokumentą. + The document's security settings don't allow copying text and images Dokumento saugos nuostatos neleidžia kopijuoti teksto ir paveikslėlių + The document's security settings don't allow this change Dokumento saugos nuostatos neleidžia atlikti šio pakeitimo + The existing action will be preserved. Esamas veiksmas bus išsaugotas. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. „PdfCraft“ naudojami šriftai negali atvaizduoti simbolio „{c}“ ({code}), todėl visose PDF žiūryklėse jis rodomas kaip ?. Tekstas įrašytas. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. Raktas saugomas „Windows“ sertifikatų saugykloje; gali būti paprašyta leisti „PdfCraft“ juo naudotis. + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. Raktas saugomas „macOS“ raktinėje; gali būti paprašyta leisti „PdfCraft“ juo naudotis. + The newest {n} of {total} PDFs Naujausi PDF failai: {n} iš {total} + The password is only used to read the file; it isn't saved. Slaptažodis naudojamas tik failui perskaityti ir nėra įrašomas. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Šio spausdinimo spausdintuvo tvarkyklės nuostatos: popieriaus dėklas, popieriaus tipas, apdaila ir kita. + The replacement file doesn't have that many pages. Pakaitiniame faile nėra tiek puslapių. + The same password is tried on each file. Kiekvienam failui bandoma naudoti tą patį slaptažodį. + The signature will be invisible. Parašas bus nematomas. + The text is the same Tekstas sutampa + The value is shown as typed. Reikšmė rodoma taip, kaip įvesta. + There are no redaction marks (mark text, areas or pages first) Nėra šalinimo žymų (pirmiausia pažymėkite tekstą, sritis arba puslapius) + These files can't be read, so they won't be in the combined PDF: Šių failų nepavyksta perskaityti, todėl jie nebus įtraukti į sujungtą PDF: + These objects could not be moved. Select them again and try a smaller move. Šių objektų nepavyko perkelti. Pasirinkite juos iš naujo ir pabandykite perkelti mažesniu atstumu. + Thick Storas + Thickness Storis + Thin Plonas + This area has too many overlapping paths; intersection snapping is limited. Šioje srityje persidengia per daug kelių; pritraukimas prie sankirtų apribotas. + This certificate is in your list of trusted certificates. Šis sertifikatas yra patikimų sertifikatų sąraše. + This certificate is not trusted. Signatures made with it show an unknown identity. Šis sertifikatas nepatikimas. Juo pasirašius rodoma nežinoma tapatybė. + This certificate is trusted through a certificate above it in the chain. Šis sertifikatas patikimas dėl aukštesnio sertifikatų grandinės sertifikato. + This comment's appearance can't be changed yet. Šio komentaro išvaizdos kol kas negalima pakeisti. + This dialog shows the details of a certificate and its chain. Šiame dialoge pateikiama sertifikato ir jo grandinės informacija. + This document contains {n} interactive form fields. Šiame dokumente yra interaktyvių formos laukų: {n}. + This document has no comments to flatten Šiame dokumente nėra suplokštinamų komentarų + This document has no document-level scripts. Šiame dokumente nėra dokumento lygmens scenarijų. + This document has no editable structure tags. Šiame dokumente nėra redaguojamų struktūros žymų. + This document has no form fields to flatten Šiame dokumente nėra suplokštinamų formos laukų + This document has no form fields you can fill in Šiame dokumente nėra pildomų formos laukų + This document has no pages. Šiame dokumente nėra puslapių. + This document has no restrictions to lift Šiam dokumentui netaikomi apribojimai, kuriuos būtų galima panaikinti + This document has no signatures. Šiame dokumente nėra parašų. + This document has no tagged figures. Šiame dokumente nėra pažymėtų paveikslų. + This document has no {kind} to change Šiame dokumente nėra keistinų elementų ({kind}) + This document hasn't been checked yet. Šis dokumentas dar netikrintas. + This document is secured. Some changes are restricted by its security settings. Dokumentas apsaugotas. Saugos nuostatos riboja kai kuriuos pakeitimus. + This document isn't password-protected Šis dokumentas neapsaugotas slaptažodžiu + This drawing exceeds the snapping limit; only the first paths are used. Brėžinys viršija pritraukimo ribą; naudojami tik pirmieji keliai. + This field is locked. Unlock it to change its properties. Šis laukas užrakintas. Atrakinkite jį, kad galėtumėte keisti savybes. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Failas diske pasikeitė. Įkelkite jį iš naujo, kad matytumėte naują versiją; neįrašyti pakeitimai bus prarasti. + This file was damaged and has been repaired. Saving keeps the repaired version. Failas buvo pažeistas ir pataisytas. Įrašius bus išsaugota pataisyta versija. + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Aplankas šiuo metu nepasiekiamas. Jis galėjo būti perkeltas arba diskas ar sinchronizavimas su debesija neveikia. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. Šios formos scenarijai išjungti, nes vienas jų veikė per ilgai. Atverkite dokumentą iš naujo, kad scenarijai būtų vykdomi vėl. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. Tai dinaminė XFA forma, kurios „PdfCraft“ dar negali rodyti. Matote faile esantį pakaitinį puslapį. + This measurement needs at least {n} points. Šiam matavimui reikia bent {n} taškų. + This page contains objects that cannot be selected together. Šiame puslapyje yra objektų, kurių negalima pasirinkti kartu. + This page couldn't be displayed.\n{e} Nepavyko parodyti šio puslapio.\n{e} + This printer offers no settings of its own. Šis spausdintuvas neturi atskirų nuostatų. + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Šio spausdintuvo tvarkyklė neperima dvipusio spausdinimo nuostatų iš kitų programų, todėl taikoma jos nuostata. Pakeiskite ją skiltyje „Savybės“… + This web address mixes letters from different alphabets, a common way to imitate another site's name. Šiame žiniatinklio adrese sumaišytos skirtingų abėcėlių raidės — taip dažnai imituojamas kitos svetainės pavadinimas. + This web address uses letters from another alphabet, which can look like familiar ones. Šiame žiniatinklio adrese naudojamos kitos abėcėlės raidės, kurios gali būti panašios į įprastas. + Tile scale: Išklotinių mastelis: + Time Laikas + Title Pavadinimas + To change locked fields, also set Locked to No. Norėdami keisti užrakintus laukus, nustatykite „Užrakinta“ į „Ne“. + To: Kam: + Today: {date} Šiandien: {date} + Toggle fit page / fit width Perjungti puslapio / pločio pritaikymą + Tool Įrankis + Tools panel side Įrankių skydelio pusė + Tooltip: Patarimas: + Top Viršus + Top-level bookmarks ({n}) Aukščiausio lygio žymelės ({n}) + Total Iš viso + Trust Pasitikėti + Try the form as people filling it in will see it Peržiūrėti formą taip, kaip ją matys pildantys žmonės + Two-Up (Cover Page) Du puslapiai (viršelio puslapis) + Two-Up (Facing) Du atgręžti puslapiai + Two-Up Continuous (Cover Page) Du puslapiai nepertraukiamai (viršelio puslapis) + Two-Up Continuous (Facing) Du atgręžti puslapiai nepertraukiamai + Two-sided: Dvipusis spausdinimas: + Type Tipas + Type text Įvesti tekstą + Type the web address. Įveskite žiniatinklio adresą. + Type your {what}. Įveskite {what}. + URL to submit to Pateikimo URL adresas + Unit Vienetas + Units Vienetai + Unknown author Nežinomas autorius + Unlock Atrakinti + Unlock {name} Atrakinti {name} + Unlock {n} files Atrakinti failus: {n} + Unlocked: the combined file won't be password-protected Atrakinta: sujungtas failas nebus apsaugotas slaptažodžiu + Unlock… Atrakinti… + Unpin Atsegti + Unpin {folder} Atsegti aplanką {folder} + Unplaced Neįdėta + Unsupported measurements Nepalaikomi matavimo vienetai + Up Aukštyn + Update Atnaujinti + Use 72 DPI (one point per pixel) Naudoti 72 DPI (vienas taškas vienam pikseliui) + Use Flate to encode streams that are not encoded Nekoduotiems srautams koduoti naudoti „Flate“ + Use a Digital ID from a file Naudoti faile esantį skaitmeninį ID + Use custom DPI Naudoti pasirinktinį DPI + Use each image's embedded DPI; use 72 DPI when it is absent. Naudoti paveikslėlyje įrašytą DPI; jei jo nėra, naudoti 72 DPI. + Use image resolution Naudoti paveikslėlio raišką + Use now Naudoti dabar + Use overlay text Naudoti perdangos tekstą + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Naudojama, kai PDF nenurodo išdėstymo ar mastelio. Nepertraukiamai slenkant puslapiai nesusiejami. + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. Naudojama atveriant PDF. Aiškiai nurodyta paleidimo ar valdymo veiksena turi pirmenybę šio seanso metu. + User Interface Options Naudotojo sąsajos parinktys + User password Naudotojo slaptažodis + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. Galioja 5 metus ir skirtas skaitmeniniams parašams. Savarankiškai pasirašytais ID kiti paprastai nepasitiki. + Validate all Tikrinti viską + Value is not calculated Reikšmė neskaičiuojama + Value is the Reikšmė yra + Value: Reikšmė: + Verify Patikrinti + Verify to see what the document needs. Patikrinkite, kad sužinotumėte, ko reikia dokumentui. + Version {v} Versija {v} + Vertical Vertikaliai + Vertical reversed Vertikaliai atvirkščiai + Vertical text cannot be moved with this selection. Vertikalaus teksto negalima perkelti pasirinkus šį objektą. + View revision {n} Peržiūrėti versiją {n} + View signed version Peržiūrėti pasirašytą versiją + Viewport Peržiūros sritis + Viewport rectangle in page points (x, y, width, height) Peržiūros srities stačiakampis puslapio taškais (x, y, plotis, aukštis) + Visible Rectangle Matomo stačiakampio anotacija + Visual Vaizdinis + Warnings Įspėjimai + Welcome to PdfCraft Sveiki atvykę į „PdfCraft“ + What word or phrase would you like to search for? Kokio žodžio ar frazės norite ieškoti? + Whole words only Tik ištisi žodžiai + Width: Plotis: + Window Options Lango parinktys + Windows store · „Windows“ sertifikatų saugykla · + Write this email? Rašyti šį el. laišką? + Wrong password Netinkamas slaptažodis + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA forma: jos laukai ir XFA duomenys sinchronizuojami, todėl kitose žiūryklėse rodoma tai, ką įvedate. + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Ketinate pritaikyti 1 šalinimo žymą. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Ketinate pritaikyti šalinimo žymų: {n}. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. + You have version {c}. All releases are listed at {page}. Turite versiją {c}. Visos laidos išvardytos čia: {page}. + You have version {c}. Download the new version from its release page. Turite versiją {c}. Atsisiųskite naują versiją iš jos laidos puslapio. + Your changes will be lost if you don't save them. Jei pakeitimų neįrašysite, jie bus prarasti. + ZIP ZIP + Zoom in Priartinti + Zoom in ({key}) Priartinti ({key}) + Zoom in / out (also pinch or {key}-scroll) Priartinti / nutolinti (taip pat suspaudžiant pirštus arba slenkant su {key}) + Zoom out Nutolinti + Zoom out ({key}) Nutolinti ({key}) + already a PDF jau yra PDF + and {n} more ir dar {n} + background fonas + characters simboliai + e.g. My stamps pvz., „Mano antspaudai“ + e.g. en-US pvz., en-US + for images above kai raiška viršija + header or footer antraštė arba poraštė + initials inicialai + is available prieinama + is not available yet dar neprieinama + just now ką tik + yesterday vakar + needs an AI provider (off by default) reikalingas DI teikėjas (išjungta pagal numatytuosius parametrus) + no output folder was chosen nepasirinktas išvesties aplankas + no unused output name nėra nenaudojamo išvesties pavadinimo + none nėra + of the following fields: šiems laukams: + AI assistant DI asistentas + Action Wizard Veiksmų vedlys + Action Wizard… Veiksmų vedlys… + Actions list Veiksmų sąrašas + Add Bates numbering… Pridėti Bates numeraciją… + Add a button Pridėti mygtuką + Add a callout Pridėti paaiškinimo laukelį + Add a checkbox Pridėti žymimąjį langelį + Add a date field Pridėti datos lauką + Add a digital signature field Pridėti skaitmeninio parašo lauką + Add a drop-down list Pridėti išskleidžiamąjį sąrašą + Add a list box Pridėti sąrašo lauką + Add a radio button Pridėti akutę + Add a stamp Pridėti antspaudą + Add a sticky note Pridėti lipnųjį raštelį + Add a text box Pridėti teksto laukelį + Add a text field Pridėti teksto lauką + Add alternate text Pridėti alternatyvųjį tekstą + Add alternate text… Pridėti alternatyvųjį tekstą… + Add an image field Pridėti paveikslėlio lauką + Add background… Pridėti foną… + Add comments Pridėti komentarus + Add content Pridėti turinį + Add files to combine Pridėti jungiamus failus + Add form components Pridėti formos komponentus + Add header & footer… Pridėti antraštę ir poraštę… + Add image… Pridėti paveikslėlį… + Add or edit links Pridėti arba redaguoti saitus + Add printer marks Pridėti spaudos žymes + Add search index Pridėti paieškos indeksą + Add text Pridėti tekstą + Add watermark… Pridėti vandenženklį… + Advanced optimization Išplėstinis optimizavimas + Advanced options Išplėstinės parinktys + Apply PDF standards Taikyti PDF standartus + Apply redactions… Pritaikyti konfidencialios informacijos šalinimą… + Archive documents Archyvuoti dokumentus + Area Plotas + Arrow Rodyklė + ArtCraft website „ArtCraft“ svetainė + Ask about this document Klausti apie šį dokumentą + Attach a file Pridėti failą + Attach file Pridėti failą + Audit space usage Patikrinti vietos naudojimą + Automatically tag PDF Automatiškai sudėti PDF žymas + Background Fonas + Bates numbering Bates numeracija + Blank page Tuščias puslapis + Bring your own model (off by default) Naudoti savo modelį (išjungta pagal numatytuosius parametrus) + Button Mygtukas + Certificates Sertifikatai + Certify (invisible signature) Patvirtinti (nematomas parašas) + Certify (visible signature) Patvirtinti (matomas parašas) + Change initials Keisti inicialus + Change reading options Keisti skaitymo parinktis + Change reading options… Keisti skaitymo parinktis… + Change signature Keisti parašą + Check for accessibility Tikrinti prieinamumą + Check for accessibility… Tikrinti prieinamumą… + Checkbox Žymimasis langelis + Checkmark Varnelė + Clear form Išvalyti formą + Clear redaction marks Pašalinti šalinimo žymas + Clipboard Iškarpinė + Cloud Debesis + Column select Stulpelio pasirinkimas + Combine Sujungti + Combine files Sujungti failus + Comment list Komentarų sąrašas + Comments panel Komentarų skydelis + Compare Palyginti + Compare files Palyginti failus + Compare files… Palyginti failus… + Compress a PDF Suglaudinti PDF + Console Konsolė + Convert colors Konvertuoti spalvas + Copy pages Kopijuoti puslapius + Correct recognized text Taisyti atpažintą tekstą + Create a PDF Kurti PDF + Create from Kurti iš + Create links from URLs Kurti saitus iš URL adresų + Crop pages Apkirpti puslapius + Cross Kryželis + Cut pages Iškirpti puslapius + Dark gray Tamsiai pilka + Date Data + Date field Datos laukas + Detect form fields Aptikti formos laukus + Digital signature Skaitmeninis parašas + Digitally sign Pasirašyti skaitmeniniu būdu + Distance Atstumas + Document JavaScripts Dokumento JavaScript scenarijai + Document JavaScripts… Dokumento JavaScript scenarijai… + Document actions Dokumento veiksmai + Dot Taškas + Draw a cloud Brėžti debesėlį + Draw a line Brėžti liniją + Draw a polygon Brėžti daugiakampį + Draw a rectangle Brėžti stačiakampį + Draw an arrow Brėžti rodyklę + Draw an oval Brėžti ovalą + Draw connected lines Brėžti sujungtas linijas + Draw freehand Brėžti laisva ranka + Drawing Brėžinys + Drawing scale Brėžinio mastelis + Drop-down list Išskleidžiamasis sąrašas + Duplicate pages Dubliuoti puslapius + Edit a PDF Redaguoti PDF + Edit text & images Redaguoti tekstą ir paveikslėlius + Encrypt with certificate Šifruoti sertifikatu + Enhance camera image Pagerinti fotoaparatu padarytą vaizdą + Enhance scanned file Pagerinti nuskaitytą failą + Enter permissions password… Įvesti teisių slaptažodį… + Erase drawings Ištrinti brėžinius + Export a PDF Eksportuoti PDF + Export all comments to data file… Eksportuoti visus komentarus į duomenų failą… + Export all images Eksportuoti visus paveikslėlius + Export all images… Eksportuoti visus paveikslėlius… + Export data Eksportuoti duomenis + Export form data… Eksportuoti formos duomenis… + Export measurements Eksportuoti matavimus + Export measurements as CSV Eksportuoti matavimus kaip CSV + Export to Eksportuoti į + Export to PowerPoint… Eksportuoti į „PowerPoint“… + Extract Išskirti + Extract pages Išskirti puslapius + Field list Laukų sąrašas + Field properties Lauko savybės + Field properties… Lauko savybės… + Fields Laukai + Fill Pildyti + Fill & Sign: add text Pildyti ir pasirašyti: pridėti tekstą + Fill & Sign: change initials Pildyti ir pasirašyti: keisti inicialus + Fill & Sign: change signature Pildyti ir pasirašyti: keisti parašą + Fill & Sign: checkmark Pildyti ir pasirašyti: varnelė + Fill & Sign: cross Pildyti ir pasirašyti: kryželis + Fill & Sign: date Pildyti ir pasirašyti: data + Fill & Sign: dot Pildyti ir pasirašyti: taškas + Fill & Sign: initials Pildyti ir pasirašyti: inicialai + Fill & Sign: line Pildyti ir pasirašyti: linija + Fill & Sign: remove saved initials Pildyti ir pasirašyti: pašalinti įrašytus inicialus + Fill & Sign: remove saved signature Pildyti ir pasirašyti: pašalinti įrašytą parašą + Fill & Sign: sign Pildyti ir pasirašyti: pasirašyti + Find text and redact Rasti tekstą ir pašalinti konfidencialią informaciją + Find text and redact… Rasti tekstą ir pašalinti konfidencialią informaciją… + Fit to width scrolling Slinkti pritaikius puslapį prie pločio + Fit visible Pritaikyti matomai sričiai + Fix hairlines Taisyti itin plonas linijas + Fix reading order Taisyti skaitymo tvarką + Flatten comments Suplokštinti komentarus + Flatten form fields Suplokštinti formos laukus + Form fields panel Formos laukų skydelis + Geospatial location Geografinė vieta + Go to page… Pereiti į puslapį… + HTML web page HTML tinklalapis + Header and footer Antraštė ir poraštė + Hide all comments Slėpti visus komentarus + Highlight text Paryškinti tekstą + Image Paveikslėlis + Image (PNG) Paveikslėlis (PNG) + Image field Paveikslėlio laukas + Images Paveikslėliai + Import comments… Importuoti komentarus… + Import data Importuoti duomenis + Import form data… Importuoti formos duomenis… + In multiple files Keliuose failuose + In this file Šiame faile + Index Indeksas + Initials Inicialai + Insert Įterpti + Insert pages Įterpti puslapius + Insert text Įterpti tekstą + JavaScript JavaScript + JavaScript console… JavaScript konsolė… + Join the ArtCraft Discord Prisijungti prie „ArtCraft“ „Discord“ serverio + Light gray Šviesiai pilka + Line Linija + Link Saitas + List box Sąrašo laukas + Make accessible Pritaikyti prieinamumui + Manage embedded index Tvarkyti įdėtąjį indeksą + Markup Žymėjimas + Marquee zoom Didinti pažymėtą sritį + Measure area Matuoti plotą + Measure distance Matuoti atstumą + Measure objects Matuoti objektus + Measure perimeter Matuoti perimetrą + Measurement information Matavimo informacija + Measurement snapping Matavimo pritraukimas + Merge data files into spreadsheet… Sujungti duomenų failus į skaičiuoklę… + Microsoft Word (.docx) „Microsoft Word“ (.docx) + Modify page Keisti puslapį + Multiple files Keli failai + New bookmarks from structure Kurti žymeles pagal struktūrą + Open accessibility report Atverti prieinamumo ataskaitą + Optimize scanned documents Optimizuoti nuskaitytus dokumentus + Optimize scanned documents… Optimizuoti nuskaitytus dokumentus… + Output preview Išvesties peržiūra + Oval Ovalas + PDF/A… PDF/A… + Page grid Puslapių tinklelis + Page labels Puslapių žymos + Page options Puslapio parinktys + Page transitions Puslapių perėjimai + Paste pages Įdėti puslapius + PdfCraft on GitHub „PdfCraft“ „GitHub“ puslapis + PdfCraft web page „PdfCraft“ svetainė + Perimeter Perimetras + Pin folder to Home… Prisegti aplanką prie pradžios lango… + PostScript / EPS PostScript / EPS + Preflight Parengiamoji patikra + Prepare for accessibility Parengti prieinamumui + Prepare for distribution Parengti platinimui + Prepare for distribution… Parengti platinimui… + Presentation (.pptx) Pateiktis (.pptx) + Print production Spaudos paruošimas + Protect a PDF Apsaugoti PDF + Protect with password Apsaugoti slaptažodžiu + Radio button Akutė + Recognize text Atpažinti tekstą + Recognize text in multiple files… Atpažinti tekstą keliuose failuose… + Recognize text… Atpažinti tekstą… + Rectangle Stačiakampis + Redact a PDF Pašalinti konfidencialią informaciją iš PDF + Redact pages Pašalinti konfidencialią informaciją iš puslapių + Redact pages… Pašalinti konfidencialią informaciją iš puslapių… + Redact text and images Pašalinti konfidencialią informaciją iš teksto ir paveikslėlių + Redaction properties… Konfidencialios informacijos šalinimo savybės… + Reduce file size Sumažinti failo dydį + Remove all links Pašalinti visus saitus + Remove background Pašalinti foną + Remove header & footer Pašalinti antraštę ir poraštę + Remove hidden information Pašalinti paslėptą informaciją + Remove hidden information… Pašalinti paslėptą informaciją… + Remove watermark Pašalinti vandenženklį + Replace Pakeisti + Replace text Pakeisti tekstą + Reset panel layout Atkurti skydelių išdėstymą + Review Peržiūrėti + Rich Text Format (.rtf) Rich Text Format (.rtf) + Rotate Pasukti + Rotate pages Pasukti puslapius + Rotate pages clockwise Pasukti puslapius pagal laikrodžio rodyklę + Rotate pages counterclockwise Pasukti puslapius prieš laikrodžio rodyklę + Sanitize document Pašalinti dokumento paslėptą informaciją + Sanitize document… Pašalinti dokumento paslėptą informaciją… + Save as PDF/A Įrašyti kaip PDF/A + Save as PDF/UA Įrašyti kaip PDF/UA + Save as PDF/X Įrašyti kaip PDF/X + Scan & OCR Nuskaityti ir atpažinti tekstą + Security properties Apsaugos savybės + Select files to compare Pasirinkti lyginamus failus + Separate PDFs Atskiri PDF dokumentai + Set measurement scale Nustatyti matavimo mastelį + Set page boxes Nustatyti puslapio sritis + Set page boxes… Nustatyti puslapio sritis… + Set properties Nustatyti savybes + Show cover page in two-page view Rodyti viršelio puslapį dviejų puslapių rodinyje + Signature Parašas + Signatures panel Parašų skydelis + Single file Vienas failas + Snapping Pritraukimas + Split Skaidyti + Spreadsheet (.xlsx) Skaičiuoklė (.xlsx) + Squiggly underline Banguota pabraukta linija + Squiggly underline text Banguotai pabraukti tekstą + Stamp Antspaudas + Stamp palette Antspaudų paletė + Stamps Antspaudai + Standards Standartai + Sticky note Lipnusis raštelis + Strikethrough text Perbraukti tekstą + Summarize Apibendrinti + Summarize comments Apibendrinti komentarus + Switch light / dark theme Perjungti šviesiąją ir tamsiąją temas + Tab order by columns Skirtukų tvarka pagal stulpelius + Tab order by rows Skirtukų tvarka pagal eilutes + Tab order: by columns Skirtukų tvarka: pagal stulpelius + Tab order: by document structure Skirtukų tvarka: pagal dokumento struktūrą + Tab order: by rows Skirtukų tvarka: pagal eilutes + Tags panel Žymų skydelis + Text (plain) Paprastasis tekstas + Text box Teksto laukelis + Text field Teksto laukas + Timestamp Laiko žyma + Translate Versti + Underline text Pabraukti tekstą + Update background… Atnaujinti foną… + Update header & footer… Atnaujinti antraštę ir poraštę… + Update watermark… Atnaujinti vandenženklį… + Use JavaScript Naudoti JavaScript + Use a certificate Naudoti sertifikatą + Use guided actions Naudoti vedamuosius veiksmus + Use print production Naudoti spaudos paruošimo įrankius + Use system setting Naudoti sistemos nuostatą + Validate all signatures Patikrinti visus parašus + Watermark Vandenženklis + Move object Perkelti objektą + Move objects Perkelti objektus + All tools Visi įrankiai + Read Skaityti + E-Sign Pasirašyti + Tools Įrankiai + Inspector Inspektorius + on page {label} puslapyje {label} + A font isn't embedded Šriftas neįdėtas + A {} action isn't allowed Veiksmas {} neleidžiamas + A {} annotation has additional actions Anotacija {} turi papildomų veiksmų + A {} annotation has no appearance stream Anotacija {} neturi išvaizdos srauto + A {} annotation isn't set to print, or is hidden Anotacija {} nenumatyta spausdinti arba yra paslėpta + Accessibility Report Prieinamumo ataskaita + Add the PDFs to combine Pridėkite jungiamus PDF failus + Added the first {n} PDFs in {folder} Pridėti pirmieji {n} PDF failai iš aplanko {folder} + CRL Signing Sertifikatų atšaukimo sąrašo pasirašymas + Certificate Signing Sertifikatų pasirašymas + Change Author Keisti autorių + Change Keywords Keisti raktažodžius + Change Subject Keisti temą + Change Title Keisti pavadinimą + Choose a digital ID. Pasirinkite skaitmeninį ID. + Create links Kurti saitus + Creator Kūrimo programa + Cut and stack needs Two-sided: Off. Print single-sided sheets. Pjovimui ir sudėjimui reikia išjungti dvipusį spausdinimą. Spausdinkite vienpusius lapus. + Data Encipherment Duomenų šifravimas + Decipher Only Tik iššifravimas + Detailed Report Išsami ataskaita + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile Naudojama „DeviceCMYK“, tačiau PDF/A išvesties profilis nėra CMYK + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile Naudojama „DeviceRGB“, tačiau PDF/A išvesties profilis nėra RGB + Digital Signature Skaitmeninis parašas + Document properties (⌘D) Dokumento savybės (⌘D) + Drop PDFs or folders here, or choose them. Nuvilkite PDF failus ar aplankus čia arba pasirinkite juos. + Encipher Only Tik šifravimas + English Anglų + Enter a URL Įveskite URL adresą + Enter a name. Įveskite pavadinimą. + Enter a page number Įveskite puslapio numerį + Enter the field names Įveskite laukų pavadinimus + Every PDF in the folder Visi aplanko PDF failai + Every PDF in the folder and the folders inside it Visi aplanko ir jo poaplankių PDF failai + Field "{}" on page {p} has no description (tooltip) Laukas „{}“ puslapyje {p} neturi aprašo (patarimo) + Filename Failo vardas + Flatten Fill & Sign Suplokštinti pildymo ir pasirašymo turinį + Font (unnamed) on page {p} doesn't map its characters to Unicode Šriftas be pavadinimo puslapyje {p} nesusieja simbolių su „Unicode“ + Font {} on page {p} doesn't map its characters to Unicode Šriftas {} puslapyje {p} nesusieja simbolių su „Unicode“ + Form field on page {p} isn't tagged Formos laukas puslapyje {p} neturi struktūros žymos + H{n} follows an H{d}; headings should not skip levels Po H{d} eina H{n}; antraščių lygiai neturi būti praleidžiami + Inserted text Įterptas tekstas + Key Agreement Raktų suderinimas + Key Encipherment Raktų šifravimas + Margins (in) Paraštės (col.) + New bookmark (⌘B) Nauja žymelė (⌘B) + Next (⌘G) Kitas (⌘G) + No PDFs found in {folder} Aplanke {folder} nerasta PDF failų + No document language is set Dokumento kalba nenustatyta + No page has text: the document appears to be images of pages Nė viename puslapyje nėra teksto: dokumentą, atrodo, sudaro puslapių paveikslėliai + Non-Repudiation Neišsižadėjimas + Only the document's owner can change its security (open it with the permissions password) Dokumento apsaugą gali keisti tik savininkas (atverkite dokumentą naudodami teisių slaptažodį) + Open a PDF (⌘O) Atverti PDF (⌘O) + Page {p} doesn't tab in structure order Puslapyje {p} tabuliavimo tvarka neatitinka struktūros tvarkos + Page {p}: {n} untagged content item Puslapis {p}: turinio elementų be žymų: {n} + Page {p}: {n} untagged content items Puslapis {p}: turinio elementų be žymų: {n} + Page {} has additional actions Puslapis {} turi papildomų veiksmų + Panel layout could not be restored: {error} Nepavyko atkurti skydelių išdėstymo: {error} + Panel move was not applied: {error} Skydelio perkėlimas nepritaikytas: {error} + PdfCraft can't check this signature yet: {}. „PdfCraft“ dar negali patikrinti šio parašo: {}. + Previous (⇧⌘G) Ankstesnis (⇧⌘G) + Print (⌘P) Spausdinti (⌘P) + Producer PDF kūrimo programa + Redo {action} Pakartoti {action} + Rendering… Atvaizduojama… + Report created Ataskaita sukurta + Rev. {rev}: Certified by an unknown signer Versija {rev}: patvirtino nežinomas pasirašiusysis + Rev. {rev}: Certified by {name} Versija {rev}: patvirtino {name} + Rev. {rev}: Signed by an unknown signer Versija {rev}: pasirašė nežinomas pasirašiusysis + Rev. {rev}: Signed by {name} Versija {rev}: pasirašė {name} + Rotate view clockwise (⇧⌘+) Pasukti rodinį pagal laikrodžio rodyklę (⇧⌘+) + Rule Name Taisyklės pavadinimas + Save (M4) Įrašyti (M4) + Save as {level} Įrašyti {level} formatu + Signature is invalid Parašas negalioja + Signature is valid Parašas galioja + Signature validity is unknown Parašo galiojimas nežinomas + Signing time is from the clock on the signer's computer. Pasirašymo laikas nustatytas pagal pasirašiusiojo kompiuterio laikrodį. + Still rendering — this page is unusually complex… Vis dar atvaizduojama — šis puslapis neįprastai sudėtingas… + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. ECDSA parašas nėra kanoninio DER formato (yra papildomų baitų, užpildo arba trūksta ženklo baito); jis vis tiek perskaitytas. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. ECDSA parašas pateiktas kaip neapdorotos r ir s reikšmės, o ne DER seka; jis vis tiek perskaitytas. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. RSA parašo reikšmės ilgis neatitinka rakto modulio ilgio (pradžioje pašalinti arba pridėti nuliniai baitai); ji vis tiek perskaityta. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA parašo „DigestInfo“ neturi parametro NULL (taip įrašo senesnės pasirašymo programos); jis vis tiek perskaitytas. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Subjekto {} sertifikatas neleidžia tiek žemesnio lygio sertifikavimo įstaigų sertifikatų (grandinės ilgio riba), todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Subjekto {} sertifikatas nėra sertifikavimo įstaigos sertifikatas, leidžiantis išduoti sertifikatus, todėl juo nepatvirtinamas {} sertifikatas. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Subjekto {} sertifikatas pasirašymo metu negaliojo, todėl juo nepatvirtinamas {} sertifikatas. + The checker found problems which may prevent the document from being fully accessible. Tikrintuvas aptiko problemų, dėl kurių dokumentas gali būti nevisiškai pritaikytas prieinamumui. + The document has been altered or corrupted since the signature was applied. Po pasirašymo dokumentas buvo pakeistas arba pažeistas. + The document has been altered or corrupted since the timestamp was applied. Pridėjus laiko žymą dokumentas buvo pakeistas arba pažeistas. + The document has been altered since this signature was applied in ways it does not permit ({}). Po pasirašymo dokumentas pakeistas parašo neleidžiamais būdais ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). Pridėjus šią laiko žymą dokumentas pakeistas jos neleidžiamais būdais ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). Po pasirašymo dokumentas pakeistas, tačiau pakeitimai leidžiami ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). Pridėjus šią laiko žymą dokumentas pakeistas, tačiau pakeitimai leidžiami ({}). + The document has no tags (structure tree) Dokumentas neturi žymų (struktūros medžio) + The document has no title Dokumentas neturi pavadinimo + The document isn't marked as tagged (MarkInfo Marked) Dokumentas nepažymėtas kaip turintis struktūros žymas (MarkInfo Marked) + The document isn't tagged, so none of its content is Dokumentas neturi struktūros žymų, todėl jų neturi ir jo turinys + The document's embedded revocation information shows that the signer's certificate has not been revoked. Dokumente įdėta atšaukimo informacija rodo, kad pasirašiusiojo sertifikatas neatšauktas. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Įdėtas laiko žymos žetonas galioja, o ją išdavusi įstaiga patikima; patikimas laikas: {}. + The font {} isn't embedded Šriftas {} neįdėtas + The metadata doesn't identify the file as {} Metaduomenys nenurodo, kad failas atitinka {} + The password is incorrect. Slaptažodis netinkamas. + The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. + The passwords do not match. Slaptažodžiai nesutampa. + The security settings don't allow screen readers to read the text Saugos nuostatos neleidžia ekrano skaitymo programoms skaityti teksto + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. Parašo algoritmas ir „SignerInfo“ santraukos algoritmas nurodo skirtingas maišos funkcijas; pasirašyta naudojant „SignerInfo“ nurodytą funkciją. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. Parašas turi nepatikrintą laiko žymą ({}): ją išdavusios įstaigos nėra patikimų sertifikatų sąraše, todėl naudojamas pasirašiusiojo nurodytas pasirašymo laikas. + The signature contents are not hexadecimal. Parašo turinys nėra šešioliktainis. + The signature could not be checked ({}). Nepavyko patikrinti parašo ({}). + The signature could not be read ({}). Nepavyko perskaityti parašo ({}). + The signature could not be verified ({}). Nepavyko patvirtinti parašo ({}). + The signature has no valid byte range. Parašas neturi tinkamo baitų intervalo. + The signature includes an embedded timestamp. Parašas turi įdėtą laiko žymą. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. Parašas koduotas BER formatu (neapibrėžti ilgiai), o ne DER; jis perskaitytas taikant lankstesnes taisykles. + The signature uses the legacy adbe.x509.rsa_sha1 format. Parašas naudoja pasenusį adbe.x509.rsa_sha1 formatą. + The signature value does not match the signer's certificate: the signature is corrupt. Parašo reikšmė neatitinka pasirašiusiojo sertifikato: parašas pažeistas. + The signature's byte range does not exclude exactly its contents. Parašo baitų intervalas neišskiria tiksliai jo turinio. + The signature's byte range does not match the file. Parašo baitų intervalas neatitinka failo. + The signature's timestamp token could not be verified ({}). Nepavyko patvirtinti parašo laiko žymos žetono ({}). + The signature's timestamp token does not cover the signature value. Parašo laiko žymos žetonas neapima parašo reikšmės. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Pasirašytas turinys yra sudėtinis (segmentuotas) OCTET STRING, kaip leidžia BER; jis vis tiek perskaitytas. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). Pasirašiusiojo sertifikatas neleidžia skaitmeninių parašų (rakto paskirtyje nenurodyta nei pasirašymo, nei neišsižadėjimo paskirtis). + The signer's certificate has been revoked ({}). Pasirašiusiojo sertifikatas atšauktas ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). Pasirašiusiojo sertifikatas turi kritinių plėtinių, kurių „PdfCraft“ neatpažįsta ({}). + The signer's certificate is not in the signature. Paraše nėra pasirašiusiojo sertifikato. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Pasirašiusiojo sertifikatas neskirtas dokumentams pasirašyti (išplėstinėje rakto paskirtyje nenurodytas dokumentų pasirašymas). + The signer's certificate was not valid at the time of signing. Pasirašiusiojo sertifikatas pasirašymo metu negaliojo. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Pasirašiusiojo tapatybė nežinoma, nes jo sertifikato nėra patikimų sertifikatų sąraše ir nė vienas aukštesnio lygio sertifikatas nėra patikimas. + The signer's identity is valid. Pasirašiusiojo tapatybė patvirtinta. + The timestamp authority's certificate was not valid at the time of timestamping. Laiko žymą išdavusios įstaigos sertifikatas žymos sukūrimo metu negaliojo. + The timestamp contents are not hexadecimal. Laiko žymos turinys nėra šešioliktainis. + The timestamp has no valid byte range. Laiko žyma neturi tinkamo baitų intervalo. + The timestamp token could not be read ({}). Nepavyko perskaityti laiko žymos žetono ({}). + The timestamp token is valid and its authority is trusted. Laiko žymos žetonas galioja, o jį išdavusi įstaiga patikima. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Laiko žymos žetonas galioja, tačiau ją išdavusios įstaigos nėra patikimų sertifikatų sąraše. + The timestamp's byte range does not exclude exactly its contents. Laiko žymos baitų intervalas neišskiria tiksliai jos turinio. + The timestamp's byte range does not match the file. Laiko žymos baitų intervalas neatitinka failo. + The window shows the file name, not the title (Initial View > Show) Lange rodomas failo vardas, o ne dokumento pavadinimas („Pradinis rodinys“ > „Rodyti:“) + There is no folder to save the digital ID in. Nėra aplanko skaitmeniniam ID įrašyti. + This document has not been modified since this signature was applied. Po pasirašymo dokumentas nepakeistas. + This document has not been modified since this timestamp was applied. Pridėjus šią laiko žymą dokumentas nepakeistas. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Šis parašas naudoja RIPEMD-160 — pasenusį 160 bitų maišos algoritmą, kuris parašams neberekomenduojamas. Parašas vis dar patvirtinamas, tačiau juo nereikėtų pasikliauti. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Šis parašas naudoja silpną SHA-1 maišos algoritmą, kuriam įrodytos kolizijos. Parašas vis dar patvirtinamas, nes SHA-1 naudoja daug prieš daugelį metų pasirašytų dokumentų, tačiau juo nereikėtų pasikliauti. + Undo {action} Atšaukti {action} + Unsigned signature field Nepasirašytas parašo laukas + Unsigned signature field: {name} Nepasirašytas parašo laukas: {name} + Zoom in (⌘+) Priartinti (⌘+) + Zoom in / out (also pinch or ⌘-scroll) Priartinti / nutolinti (taip pat suspaudžiant pirštus arba slenkant su ⌘) + Zoom out (⌘−) Nutolinti (⌘−) + alternate text hides an annotation inside it alternatyvusis tekstas paslepia vidinę anotaciją + alternate text on an element with no page content alternatyvusis tekstas elemente be puslapio turinio + comments komentarai + document security store dokumento saugos saugykla + document structure dokumento struktūra + form fill formos pildymas + its alternate text is inside another element's and will never be read alternatyvusis tekstas yra kito elemento alternatyviojo teksto viduje ir nebus perskaitytas + links saitai + metadata metaduomenys + no alternate text nėra alternatyviojo teksto + no header cells (TH) nėra antraščių langelių (TH) + no summary nėra santraukos + not in a TR nepriklauso TR + not in a Table, THead, TBody or TFoot nepriklauso Table, THead, TBody arba TFoot + not in an L (list) nepriklauso L (sąrašui) + not in an LI (list item) nepriklauso LI (sąrašo elementui) + other changes kiti pakeitimai + page content puslapio turinys + page {n} is out of range (1–{count}) puslapis {n} nepatenka į intervalą (1–{count}) + pages added or removed puslapių pridėjimas arba šalinimas + rows have between {min} and {max} columns eilutėse yra nuo {min} iki {max} stulpelių + the first heading is an H{n}; headings should not skip levels pirmoji antraštė yra H{n}; antraščių lygiai neturi būti praleidžiami + the signed version could not be read nepavyko perskaityti pasirašytos versijos + {n} pages and no bookmarks Puslapių: {n}, žymelių nėra + {page} is not a page number or label {page} nėra puslapio numeris ar žyma + {ty} element on page {p}: {what} Elementas {ty} puslapyje {p}: {what} + {ty} element: {what} Elementas {ty}: {what} + {who}, certificate issued by an unknown issuer. {who}, sertifikatą išdavė nežinomas išdavėjas. + {who}, certificate issued by {issuer}. {who}, sertifikatą išdavė {issuer}. + {} annotation on page {p} isn't tagged Anotacija {} puslapyje {p} neturi struktūros žymos + {} annotations aren't allowed Anotacijos {} neleidžiamos + … and {n} more … ir dar {n} +@plural Delete {n} field|Delete {n} fields Naikinti {n} lauką|Naikinti {n} laukus|Naikinti {n} laukų +@plural {n} byte|{n} bytes {n} baitas|{n} baitai|{n} baitų +@plural {n} image optimized|{n} images optimized Optimizuotas {n} paveikslėlis|Optimizuoti {n} paveikslėliai|Optimizuota {n} paveikslėlių +@plural {n} item discarded|{n} items discarded Pašalintas {n} elementas|Pašalinti {n} elementai|Pašalinta {n} elementų +@plural {n} pixel|{n} pixels {n} pikselis|{n} pikseliai|{n} pikselių +action wizard Start Pradėti +comment menu Edit Redaguoti + A button Mygtukas + A diagonal CONFIDENTIAL watermark on every page. Įstrižas vandenženklis „KONFIDENCIALU“ kiekviename puslapyje. + A document needs at least one page: copy instead Dokumente turi likti bent vienas puslapis: vietoj perkėlimo kopijuokite + A form field has additional actions Formos laukas turi papildomų veiksmų + A link Saitas + A script Scenarijus + A selected field has no widget on any page, so the fields can't be edited together. Pasirinktas laukas neturi valdiklio jokiame puslapyje, todėl laukų negalima redaguoti kartu. + A stream refers to an external file Srautas nurodo išorinį failą + A stream uses LZW compression Srautas suglaudintas naudojant LZW + A summary describes the table's purpose and layout. Add a Summary attribute to each table. Santrauka apibūdina lentelės paskirtį ir išdėstymą. Kiekvienai lentelei pridėkite atributą „Summary“. + About Apie programą + Accepted Priimta + Accessibility permission flag Prieinamumo leidimo požymis + Accessibility permission flag is set Prieinamumo leidimo požymis nustatytas + Add Page Numbers Pridėti puslapių numerius + Add arrow Pridėti rodyklę + Add background Pridėti foną + Add bookmark Pridėti žymelę + Add callout Pridėti išnašą + Add checkmark Pridėti varnelę + Add cloud Pridėti debesies kontūrą + Add connected lines Pridėti sujungtas linijas + Add cross Pridėti kryželį + Add dot Pridėti tašką + Add drawing Pridėti piešinį + Add field Pridėti lauką + Add file attachment Pridėti failo priedą + Add footer Pridėti poraštę + Add header Pridėti antraštę + Add header & footer Pridėti antraštę ir poraštę + Add highlight Pridėti paryškinimą + Add image Pridėti paveikslėlį + Add inserted text Pridėti įterpiamą tekstą + Add line Pridėti liniją + Add link Pridėti saitą + Add oval Pridėti ovalą + Add polygon Pridėti daugiakampį + Add rectangle Pridėti stačiakampį + Add redaction mark Pridėti konfidencialios informacijos šalinimo žymą + Add signature Pridėti parašą + Add squiggly underline Pridėti banguotą pabraukimą + Add stamp Pridėti antspaudą + Add sticky note Pridėti lipnųjį raštelį + Add strikethrough Pridėti perbraukimą + Add text box Pridėti teksto langelį + Add underline Pridėti pabraukimą + Add watermark Pridėti vandenženklį + Add {what} Pridėti {what} + Added the first {n} files in {folder} Pridėti pirmieji {n} failai iš aplanko {folder} + Adding images arrives on the web with file pickers for images Žiniatinklio versijoje paveikslėlius bus galima pridėti įdiegus jų failų pasirinkimą + Algorithm Algoritmas + Align fields Lygiuoti laukus + All annotations are tagged Visos anotacijos turi struktūros žymas + All form fields are tagged Visi formos laukai turi struktūros žymas + All form fields have description Visi formos laukai turi aprašą + All multimedia objects are tagged Visi daugialypės terpės objektai turi struktūros žymas + All page content is tagged Visas puslapio turinys turi struktūros žymas + Allowed Leidžiama + Alternate Text Alternatyvusis tekstas + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. Elemento, kurio pirminis elementas jau turi alternatyvųjį tekstą, alternatyvusis tekstas neskaitomas. Pašalinkite vidinį alternatyvųjį tekstą. + Alternate text must be associated with some content Alternatyvusis tekstas turi būti susietas su turiniu + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. Elemento, kuriame yra saitas arba laukas, alternatyvusis tekstas paslepia anotaciją nuo pagalbinių technologijų. Perkelkite alternatyvųjį tekstą. + Alternate text on an element without page content is never reached. Remove it or attach the element to content. Su puslapio turiniu nesusieto elemento alternatyvusis tekstas nepasiekiamas. Pašalinkite jį arba susiekite elementą su turiniu. + Alternate text should not hide annotation Alternatyvusis tekstas neturi paslėpti anotacijos + Alternate text that will never be read Alternatyvusis tekstas, kuris nebus perskaitytas + An action is already running Veiksmas jau vykdomas + An embedded file has no AFRelationship Įdėtasis failas neturi atributo „AFRelationship“ + An image asks to be interpolated Paveikslėliui nurodyta taikyti interpoliaciją + An image has alternates or OPI information Paveikslėlis turi alternatyvių versijų arba OPI informacijos + Animation that flashes can cause seizures. Check scripts and media for flicker. Mirganti animacija gali sukelti priepuolius. Patikrinkite, ar scenarijai ir medija nesukelia mirgėjimo. + Another file dialog is still open. Finish with it first. Kitas failų dialogas dar atvertas. Pirmiausia užbaikite veiksmus jame. + Application Programa + Applied 1 redaction mark. Save to remove the content from the file. Pritaikyta 1 konfidencialios informacijos šalinimo žyma. Įrašykite, kad turinys būtų pašalintas iš failo. + Applied {n} redaction marks. Save to remove the content from the file. Pritaikyta konfidencialios informacijos šalinimo žymų: {n}. Įrašykite, kad turinys būtų pašalintas iš failo. + Appropriate nesting Tinkamas įdėjimas + Arbitrary Mask Pasirinktinė kaukė + Associated with content Susieta su turiniu + At least one signature has problems. Bent vienas parašas turi problemų. + At least one signature is invalid. Bent vienas parašas negalioja. + Attachment Priedas + Basic constraints Pagrindiniai apribojimai + Beveled Nuožulnus + Black Juoda + Black on white Juoda baltame fone + Blue Mėlyna + Bookmarks are present in large documents Dideli dokumentai turi žymeles + Both Abu + Button Properties Mygtuko savybės + Button Style: Mygtuko stilius: + Calculate Skaičiavimas + Callout Išnaša + Cancelled Atšaukta + Center Footer Text Vidurinis poraštės tekstas + Center Header Text Vidurinis antraštės tekstas + Center horizontally Centruoti horizontaliai + Center vertically Centruoti vertikaliai + Certificate Viewer Sertifikatų žiūryklė + Certified by {by} Patvirtino {by} + Change comment properties Keisti komentaro savybes + Change document properties Keisti dokumento savybes + Change field properties Keisti lauko savybes + Change initial view Keisti pradinį rodinį + Change link properties Keisti saito savybes + Change tag properties Keisti žymos savybes + Change {key} Keisti {key} + Changing the document Dokumento keitimas + Character encoding Simbolių koduotė + Character limit: Simbolių skaičiaus riba: + Check Varnelė + Check Box Properties Žymimojo langelio savybės + Check Box Style: Žymimojo langelio stilius: + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Patikrinkite, ar žymos skaitomos ta tvarka, kuria skaitytų matantis žmogus. Tvarką taisykite skaitymo tvarkos įrankiu arba žymų skydelyje. + CheckBox Žymimasis langelis + Checked Pažymėta + Choose a folder for the split files Pasirinkite aplanką suskaidytiems failams + Choose a folder on this computer to pin Pasirinkite prisegamą šio kompiuterio aplanką + Click on the page to add a {tool}, or drag to set its size Spustelėkite puslapį, kad pridėtumėte {tool}, arba vilkite, kad nustatytumėte dydį + Click text or an image to edit it Spustelėkite tekstą ar paveikslėlį, kad jį redaguotumėte + Color Images Spalvoti paveikslėliai + Color Spaces Spalvų erdvės + Color contrast Spalvų kontrastas + Comb of characters Simboliai atskiruose langeliuose + Combo Išskleidžiamasis sąrašas + Coming in milestone {m}. Items marked Ready work today. Planuojama {m} etape. Paruoštais pažymėti elementai jau veikia. + Command Komanda + Commenting Komentavimas + Comments and markups Komentarai ir žymėjimai + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. Komentarai, saitai ir laukai turi būti žymų medyje, kad būtų perskaitomi. Pridėkite anotacijų žymas. + Completed Baigta + Configure a Digital ID for Signing Konfigūruoti skaitmeninį ID pasirašymui + Connected lines Sujungtos linijos + Content Streams Turinio srautai + Content copying Turinio kopijavimas + Content copying for accessibility Turinio kopijavimas prieinamumui + Contributors Bendraautoriai + Copied {what} Nukopijuota: {what} + Copy or cut pages first Pirmiausia nukopijuokite arba iškirpkite puslapius + Could not save: {e} Nepavyko įrašyti: {e} + Couldn't add {name}: {e} Nepavyko pridėti {name}: {e} + Couldn't combine files: {e} Nepavyko sujungti failų: {e} + Couldn't create a PDF: {e} Nepavyko sukurti PDF: {e} + Couldn't download {name}: {e} Nepavyko atsisiųsti {name}: {e} + Couldn't extract pages: {e} Nepavyko išskirti puslapių: {e} + Couldn't fit the visible content: {e} Nepavyko pritaikyti matomo turinio: {e} + Couldn't follow linked PDF: {reason} Nepavyko atverti susieto PDF: {reason} + Couldn't import the signature image: {e} Nepavyko importuoti parašo paveikslėlio: {e} + Couldn't open revision {n}: {e} Nepavyko atverti versijos {n}: {e} + Couldn't open the result: {e} Nepavyko atverti rezultato: {e} + Couldn't open the signed version: {e} Nepavyko atverti pasirašytos versijos: {e} + Couldn't open {name}: {e} Nepavyko atverti {name}: {e} + Couldn't optimize the file Nepavyko optimizuoti failo + Couldn't optimize the file: {e} Nepavyko optimizuoti failo: {e} + Couldn't print from the browser ({e}); downloaded {name} to print instead Nepavyko spausdinti iš naršyklės ({e}); vietoj to atsisiųstas spausdintinas failas {name} + Couldn't read {name}: {e} Nepavyko perskaityti {name}: {e} + Couldn't recover {name}: {e} Nepavyko atkurti {name}: {e} + Couldn't reload {name}: {e} Nepavyko iš naujo įkelti {name}: {e} + Couldn't save the image: {e} Nepavyko įrašyti paveikslėlio: {e} + Couldn't save {name}: {e} Nepavyko įrašyti {name}: {e} + Couldn't save: {e} Nepavyko įrašyti: {e} + Couldn't show the file picker. Please try again. Nepavyko parodyti failų pasirinkimo lango. Bandykite dar kartą. + Couldn't split: {e} Nepavyko suskaidyti: {e} + Couldn't summarize comments: {e} Nepavyko apibendrinti komentarų: {e} + Couldn't take the snapshot: {e} Nepavyko padaryti momentinės kopijos: {e} + Couldn't unlock {name}: {e} Nepavyko atrakinti {name}: {e} + Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} + Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} + Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} + Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} + Create link Kurti saitą + Created 1 link Sukurtas 1 saitas + Created a PDF from the first {n} files PDF sukurtas iš pirmųjų {n} failų + Created a comment summary Sukurta komentarų suvestinė + Created {n} links Sukurta saitų: {n} + Credit Cards Mokėjimo kortelių numeriai + Crop page Apkirpti puslapį + Custom font Pasirinktinis šriftas + Custom stamps arrive on the web with file pickers for images Žiniatinklio versijoje pasirinktiniai antspaudai bus prieinami įdiegus paveikslėlių failų pasirinkimą + Cut {what} Iškirpta: {what} + Dark gray theme Tamsiai pilka tema + Dashed Brūkšninis + Dates Datos + Delete bookmark Naikinti žymelę + Delete content Naikinti turinį + Delete document JavaScript Naikinti dokumento „JavaScript“ scenarijų + Delete field Naikinti lauką + Delete image Naikinti paveikslėlį + Delete link Naikinti saitą + Delete page Naikinti puslapį + Delete selected fields Naikinti pasirinktus laukus + Deleted Pašalinta + Detected 1 form field Aptiktas 1 formos laukas + Detected {n} form fields Aptikta formos laukų: {n} + Device colour is used, but there is no PDF/A output intent Naudojamos įrenginio spalvos, tačiau nėra PDF/A išvesties profilio + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) Naudojama „DeviceCMYK“: reikalingas CMYK išvesties profilis (automatiškai nepridedamas) + Diagnostic details Diagnostikos informacija + Digital Signature Properties Skaitmeninio parašo savybės + Discard all comments, forms and multimedia Pašalinti visus komentarus, formas ir daugialypės terpės turinį + Discard all links, actions and JavaScript Pašalinti visus saitus, veiksmus ir „JavaScript“ scenarijus + Discard all object data (file attachments) Pašalinti visus objektų duomenis (failų priedus) + Discard bookmarks Pašalinti žymeles + Discard document information and metadata Pašalinti dokumento informaciją ir metaduomenis + Discard hidden layer content Pašalinti paslėptų sluoksnių turinį + Discard hidden text Pašalinti paslėptą tekstą + Discard private data of other applications Pašalinti kitų programų privačius duomenis + Distribute fields Tolygiai išdėstyti laukus + Document Overhead Dokumento pagalbiniai duomenys + Document assembly Dokumento komponavimas + Document has appropriate color contrast Dokumento spalvų kontrastas tinkamas + Document is not image-only PDF PDF dokumentas sudarytas ne vien iš paveikslėlių + Document is tagged PDF PDF dokumentas turi struktūros žymas + Document sanitized. Save to finish: saving rewrites the whole file. Dokumentas išvalytas. Užbaikite įrašydami: bus perrašytas visas failas. + Document structure provides a logical reading order Dokumento struktūra užtikrina loginę skaitymo tvarką + Document title is showing in title bar Lango antraštėje rodomas dokumento pavadinimas + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. Daugiau nei 20 puslapių dokumentams reikia naršymo žymelių. Pridėkite pagrindinių skyrių žymeles. + Double-click Dukart spustelėti + Downloaded {name} Atsisiųsta: {name} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Žiniatinklio versijoje priedus bus galima atsisiųsti M3.10 etape (paruošta baitų: {n}) + Drag a rectangle around the area to copy Apibrėžkite kopijuojamą sritį vilkdami stačiakampį + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Vilkite stačiakampį puslapyje, kad jį apkirptumėte; dukart spustelėkite puslapį, kad nustatytumėte puslapio ribas + Drag a rectangle to create a link; double-click a link to edit it Vilkite stačiakampį, kad sukurtumėte saitą; dukart spustelėkite saitą, kad jį redaguotumėte + Drag the corners onto the document's edges, then choose Enhance page. Nutempkite kampus prie dokumento kraštų, tada pasirinkite „Pagerinti puslapį“. + Drag to draw the area where the signature should appear. Vilkite, kad apibrėžtumėte sritį, kurioje turi būti parašas. + Dropdown Properties Išskleidžiamojo sąrašo savybės + Duplicate field Dubliuoti lauką + Duplicate page Dubliuoti puslapį + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. Kiekvienam laukui reikia patarimo, nurodančio, ką įvesti. Pridėkite jį skiltyje „Lauko savybės“ > „Bendrosios“ > „Patarimas“. + Edit comment Redaguoti komentarą + Edit content Redaguoti turinį + Edit document JavaScript Redaguoti dokumento „JavaScript“ scenarijų + Edit fields Redaguoti laukus + Edit script of {name} Redaguoti {name} scenarijų + Edit text Redaguoti tekstą + Editing Redaguoti + Email Addresses El. pašto adresai + Embedded Files Įdėtieji failai + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Įdėtieji failai taip pat turi būti PDF/A formato (kitiems formatams naudokite PDF/A-3) + Encryption Šifravimas + Endpoint Galinis taškas + Erase Trinti + Eraser Trintukas + Even pages Lyginiai puslapiai + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. Visas puslapio turinys turi turėti struktūros žymas arba būti pažymėtas kaip pagalbinis elementas (puošyba, antraštės, poraštės). Pridėkite trūkstamas turinio žymas. + Execute a menu item Vykdyti meniu komandą + Export certificate Eksportuoti sertifikatą + Exported to {path} Eksportuota į {path} + Extended Graphics States Išplėstinės grafikos būsenos + Field Laukas + Field descriptions Laukų aprašai + Figures alternate text Paveikslų alternatyvusis tekstas + Figures require alternate text Paveikslams reikia alternatyviojo teksto + File attachments Failų priedai + Files are combined in this order. Leave Pages empty to take every page. Failai jungiami šia tvarka. Palikite lauką „Puslapiai“ tuščią, jei norite įtraukti visus puslapius. + Fill in {name} Pildyti {name} + Filling of form fields Formos laukų pildymas + Flatten Suplokštinti + Flip image Apversti paveikslėlį + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. Šriftų simboliai turi būti susieti su „Unicode“, kad tekstą būtų galima skaityti ir kopijuoti. Pataisykite šriftus arba iš naujo sukurkite PDF. + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. Formos laukai turi būti žymų medyje (žymos „Form“), kad būtų perskaitomi. Pridėkite formos laukų žymas. + Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulėms ir panašiems netekstiniams elementams reikia alternatyviojo teksto. Pridėkite jį. + Gray Pilka + Grayscale Images Pilkų atspalvių paveikslėliai + Green Žalia + Green on black Žalia juodame fone + H / Space (hold) H / tarpo klavišas (laikyti) + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. Antraščių langeliai (TH) nurodo klausytojui kiekvieno stulpelio ar eilutės paskirtį. Pažymėkite antraščių langelius. + Headers Antraštės + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. Antraščių lygiai neturėtų būti praleidžiami (pvz., po H1 iškart H3). Pataisykite antraščių žymas. + Headings Antraštės + Hidden layers Paslėpti sluoksniai + Hidden text Paslėptas tekstas + Hide a field Slėpti lauką + Hides annotation Paslepia anotaciją + High Didelis + Home / End Home / End + Horizontally Horizontaliai + Image-only PDF PDF vien iš paveikslėlių + Import {name} Importuoti {name} + Importing data arrives on the web with file pickers for data files Žiniatinklio versijoje duomenis bus galima importuoti įdiegus duomenų failų pasirinkimą + Incorrect password. Try again. Netinkamas slaptažodis. Bandykite dar kartą. + Insert pages from {name} Įterpti puslapius iš {name} + Insert pages from {name} — 1 field renamed Įterpti puslapius iš {name} — pervadintas 1 laukas + Insert pages from {name} — fields renamed Įterpti puslapius iš {name} — laukai pervadinti + Inserted Įterpta + Inset Įspaustas + Intended usage Naudojimo paskirtis + Intersection Sankirta + Invert Invertuoti + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). Netaisyklingos lentelės trikdo naršymą tarp langelių. Kiekvienoje eilutėje turi būti vienodas langelių skaičius, įskaitant sujungimus. + Issued by Išdavė + Issued to Išduota kam + Issuer Išdavėjas + Key usage Rakto naudojimas + Keywords Raktažodžiai + LI must be a child of L LI turi būti L antrinis elementas + Landscape pages Gulsčiai orientuoti puslapiai + Lbl and LBody Lbl ir LBody + Lbl and LBody must be children of LI Lbl ir LBody turi būti LI antriniai elementai + Left Footer Text Kairysis poraštės tekstas + Left Header Text Kairysis antraštės tekstas + Light blue Šviesiai mėlyna + Links, actions and JavaScripts Saitai, veiksmai ir „JavaScript“ scenarijai + List Sąrašas + List Box Properties Sąrašo langelio savybės + List items Sąrašo elementai + List items belong in a list. Fix the list's tags. Sąrašo elementai turi priklausyti sąrašui. Pataisykite sąrašo žymas. + List labels and bodies belong in a list item. Fix the list's tags. Sąrašo žymenys ir turinys turi priklausyti sąrašo elementui. Pataisykite sąrašo žymas. + Lists Sąrašai + Load document font Įkelti dokumento šriftą + Local cross-PDF links require the desktop app Vietiniams saitams į kitus PDF failus reikalinga darbalaukio programa + Lock comment Užrakinti komentarą + Logical Reading Order Loginė skaitymo tvarka + Low Mažas + Make scans searchable, then make them smaller. Atpažinkite nuskaitytų dokumentų tekstą, kad būtų galima ieškoti, tada sumažinkite failus. + Mark all Pažymėti viską + Mark as Confidential Pažymėti kaip konfidencialų + Mark differences Pažymėti skirtumus + Mark figure as decorative Pažymėti paveikslą kaip dekoratyvinį + Mark for redaction Pažymėti konfidencialios informacijos šalinimui + Mark pages for redaction Pažymėti puslapius konfidencialios informacijos šalinimui + Match field sizes Suvienodinti laukų dydžius + Maximum Didžiausia reikšmė + Measuring tool Matavimo įrankis + Merged 1 file into {path} 1 failas sujungtas į {path} + Merged {n} files into {path} Į {path} sujungta failų: {n} + Merging data files needs the desktop app Duomenų failams sujungti reikalinga darbalaukio programa + Metadata Metaduomenys + Midpoint Vidurio taškas + Minimum Mažiausia reikšmė + Models Modeliai + Mouse Down Paspaudus pelės mygtuką + Mouse Enter Pelės žymekliui įėjus + Mouse Exit Pelės žymekliui išėjus + Mouse Up Atleidus pelės mygtuką + Move bookmark Perkelti žymelę + Move comment Perkelti komentarą + Move image Perkelti paveikslėlį + Move page Perkelti puslapį + Move pages Perkelti puslapius + Named Destinations Pavadintos paskirties vietos + Navigation links Naršymo saitai + Navigation links are not repetitive Naršymo saitai nesikartoja + Nested alternate text Įdėtasis alternatyvusis tekstas + New {tool} comments will look like this one Nauji {tool} komentarai atrodys kaip šis + No PDFs, images or text files found in {folder} Aplanke {folder} nerasta PDF, paveikslėlių ar tekstinių failų + No form fields were detected Formos laukų neaptikta + No inaccessible scripts Nėra neprieinamų scenarijų + No links to remove Nėra šalintinų saitų + No pages match those choices Nė vienas puslapis neatitinka pasirinkimų + No unlinked web addresses were found in the text Tekste nerasta žiniatinklio adresų be saitų + None of those files can be made into a PDF; use PDFs, images or .txt files Nė vieno iš šių failų negalima paversti PDF; naudokite PDF, paveikslėlius arba .txt failus + Note Raštelis + Number every page in the footer. Sunumeruoti visus puslapius poraštėje. + Odd pages Nelyginiai puslapiai + On Blur Praradus įvesties židinį + On Focus Gavus įvesties židinį + On the web, open or drop an image to convert it Žiniatinklio versijoje atverkite arba nuvilkite paveikslėlį, kad jį konvertuotumėte + Open a web link Atverti žiniatinklio saitą + Opened with Atverta naudojant + Opening the browser's print dialog… Atveriamas naršyklės spausdinimo dialogas… + Optimization cancelled Optimizavimas atšauktas + Optimization is already running Optimizavimas jau vykdomas + Optimize Scanned Documents Optimizuoti nuskaitytus dokumentus + Options Parinktys + Orange Oranžinė + Other elements alternate text Kitų elementų alternatyvusis tekstas + Other elements that require alternate text Kiti elementai, kuriems reikia alternatyviojo teksto + Outline Kontūras + PDF producer PDF kūrimo programa + PDF version PDF versija + Page Content Puslapio turinys + Page Range Puslapių intervalas + Page does not require timed responses Puslapis nereikalauja atsakyti per ribotą laiką + Page thumbnails Puslapių miniatiūros + Page will not cause screen flicker Puslapis nesukels ekrano mirgėjimo + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Puslapių, kuriuose tekstas yra tik paveikslėlis, negalima perskaityti balsu ar juose ieškoti. Atpažinkite tekstą (OCR), kad pridėtumėte teksto sluoksnį. + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. Puslapiuose su saitais, komentarais ar laukais pereiti tabuliavimo klavišu reikia struktūros tvarka. Pataisa nustato kiekvieno puslapio tabuliavimo tvarką pagal dokumento struktūrą. + Passed Atitinka + Password protection will be applied when you save Apsauga slaptažodžiu bus pritaikyta įrašant + Path Kelias + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. „PdfCraft“ atvaizduoja be grafikos procesoriaus, todėl gali veikti lėtai. Gali padėti grafikos tvarkyklės atnaujinimas. + People may need more time to respond. Check that nothing has a time limit. Žmonėms gali reikėti daugiau laiko atsakyti. Patikrinkite, ar nėra laiko apribojimų. + Phone Number Telefono numeris + Phone Numbers Telefono numeriai + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. Paveikslėliams reikia juos apibūdinančio alternatyviojo teksto arba jie turi būti pažymėti kaip dekoratyviniai. Pridėkite kiekvieno paveikslo alternatyvųjį tekstą. + Pink Rožinė + Pinned folders need the desktop app Aplankams prisegti reikalinga darbalaukio programa + Pinned {folder} to Home Aplankas {folder} prisegtas prie pradžios rodinio + Place saved {what} Įdėti įrašytą {what} + Planned for milestone {m} — open to see what it will include Planuojama {m} etape — atverkite ir sužinokite, kas bus įtraukta + Planned · {m} Planuojama · {m} + Polygon Daugiakampis + Polyline Laužtė + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Nešiojamoji veiksena išjungta: negalima rašyti į {folder} ({error}). Nuostatos saugomos naudotojo aplanke. + Portrait pages Stačiai orientuoti puslapiai + Position Padėtis + PostScript XObjects aren't allowed „PostScript“ XObject objektai neleidžiami + Prepare for Distribution Parengti platinimui + Preparing sheet {done} of {total} for printing… Spausdinimui ruošiamas {done} lapas iš {total}… + Preparing {n} sheets for printing… Spausdinimui ruošiama lapų: {n}… + Primary language Pagrindinė kalba + Printing Spausdinimas + Private data of other applications Kitų programų privatūs duomenys + Public key Viešasis raktas + Purple Violetinė + PushButton Mygtukas + Radio Akutė + Radio Button Properties Akutės savybės + Re-type Password Pakartokite slaptažodį + Red Raudona + Redid {label} Pakartota: {label} + Regularity Taisyklingumas + Rejected Atmesta + Reliable character encoding is provided Užtikrinama patikima simbolių koduotė + Remove hidden information and flatten comments and form fields, so recipients see only the pages. Pašalinkite paslėptą informaciją ir suplokštinkite komentarus bei formos laukus, kad gavėjai matytų tik puslapius. + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Pašalinkite paslėptą informaciją, metaduomenis, scenarijus bei įdėtąjį turinį ir perrašykite failus. + Remove redaction marks Pašalinti konfidencialios informacijos šalinimo žymas + Remove saved {what} Pašalinti įrašytą {what} + Removed 1 hidden item. Save to remove them from the file. Pašalintas 1 paslėptas elementas. Įrašykite, kad jis būtų pašalintas iš failo. + Removed 1 link Pašalintas 1 saitas + Removed {n} hidden items. Save to remove them from the file. Pašalinta paslėptų elementų: {n}. Įrašykite, kad jie būtų pašalinti iš failo. + Removed {n} links Pašalinta saitų: {n} + Rename bookmark Pervadinti žymelę + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Atvaizdavimas: hayro (pradinė versija) · Sąsaja: egui · Piktogramos: Lucide (ISC) · Šriftai: Inter, JetBrains Mono, Dancing Script (OFL) + Repairs Pataisos + Repeated navigation links should be skippable. Check links that appear on every page. Pasikartojančius naršymo saitus turėtų būti galima praleisti. Patikrinkite kiekviename puslapyje rodomus saitus. + Replace page Pakeisti puslapį + Replace pages Pakeisti puslapius + Replaced Pakeista + Replacing images arrives on the web with image file pickers Žiniatinklio versijoje paveikslėlius bus galima pakeisti įdiegus jų failų pasirinkimą + Replacing images on page {p} arrives on the web with image pickers ({i}) Žiniatinklio versijoje paveikslėlius puslapyje {p} bus galima pakeisti įdiegus jų pasirinkimą ({i}) + Report Options Ataskaitos parinktys + Reset a form Išvalyti formą + Resize comment Keisti komentaro dydį + Restrictions Apribojimai + Restrictions lifted Apribojimai panaikinti + Reverted to the last saved version Grąžinta paskutinė įrašyta versija + Right Footer Text Dešinysis poraštės tekstas + Right Header Text Dešinysis antraštės tekstas + Rotate image Pasukti paveikslėlį + Rotate page Pasukti puslapį + Rotate page {label} clockwise Pasukti puslapį {label} pagal laikrodžio rodyklę + Rotate page {label} counterclockwise Pasukti puslapį {label} prieš laikrodžio rodyklę + Rows Eilutės + Run Vykdyti + Run JavaScript Vykdyti „JavaScript“ + Run a JavaScript Vykdyti „JavaScript“ scenarijų + Run form script Vykdyti formos scenarijų + SHA-1 digest SHA-1 santrauka + SHA-256 digest SHA-256 santrauka + Sanitize Išvalyti paslėptą informaciją + Sanitize Documents Išvalyti paslėptą dokumentų informaciją + Save as PDF/A-1b Įrašyti PDF/A-1b formatu + Save as PDF/A-2b Įrašyti PDF/A-2b formatu + Save as PDF/A-3b Įrašyti PDF/A-3b formatu + Save image Įrašyti paveikslėlį + Save or revert changes before selecting another tag. Prieš pasirinkdami kitą žymą įrašykite arba atmeskite pakeitimus. + Saved the print-ready PDF to {path} Spausdinimui parengtas PDF įrašytas į {path} + Saved {name} Įrašyta: {name} + Saved {place}: {before} → {after} (not smaller){detail} Įrašyta {place}: {before} → {after} (nesumažėjo){detail} + Saved {place}: {before} → {after} ({pct}% smaller){detail} Įrašyta {place}: {before} → {after} (sumažėjo {pct}%){detail} + Saved, but reopening failed: {e} Įrašyta, tačiau nepavyko atverti iš naujo: {e} + Screen flicker Ekrano mirgėjimas + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. Ekrano skaitymo programos pasirenka balsą pagal dokumento kalbą. Nustatykite ją skiltyje „Dokumento savybės“ > „Išplėstiniai“ > „Kalba“. + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. Scenarijais valdomas turinys turi būti pasiekiamas klaviatūra ir pagalbinėmis technologijomis. Patikrinkite dokumento scenarijus. + Scripts Scenarijai + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Scenarijai vykdomi izoliuotoje aplinkoje be prieigos prie failų ar tinklo. Išjungus „JavaScript“, vis tiek veikia standartinės „Acrobat“ formatavimo, tikrinimo ir skaičiavimo funkcijos. + Security method Apsaugos būdas + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Saugos nuostatos turi leisti pagalbinėms technologijoms skaityti turinį („Leisti ekrano skaitymo programoms pasiekti tekstą“). Pakeiskite dokumento saugos nuostatas, kad tai būtų leidžiama. + Security will be removed when you save Apsauga bus pašalinta įrašant + Self-signed Savarankiškai pasirašytas + Serial number Serijos numeris + Set alternate text Nustatyti alternatyvųjį tekstą + Set bookmark destination Nustatyti žymelės paskirties vietą + Set document title Nustatyti dokumento pavadinimą + Set status Accepted Nustatyti būseną „Priimta“ + Set status Cancelled Nustatyti būseną „Atšaukta“ + Set status Completed Nustatyti būseną „Baigta“ + Set status None Nustatyti būseną „Nėra“ + Set status Rejected Nustatyti būseną „Atmesta“ + Set status Unmarked Nustatyti būseną „Nežymėta“ + Set tab order Nustatyti tabuliavimo tvarką + Set the image of {name} Nustatyti {name} paveikslėlį + Shadings Spalvų perėjimai + Show a field Rodyti lauką + Sign with a Digital ID Pasirašyti naudojant skaitmeninį ID + Signed and all signatures are valid. Pasirašyta, visi parašai galioja. + Signed and saved to {path} Pasirašyta ir įrašyta į {path} + Signer Pasirašiusysis + Signing time Pasirašymo laikas + Social Security Number Socialinio draudimo numeris + Social Security Numbers Socialinio draudimo numeriai + Solid Vientisas + Squiggly Banguotas + Star Žvaigždė + Strong Stiprus + Structure Info Struktūros informacija + Submit a form Pateikti formą + TH and TD TH ir TD + TH and TD must be children of TR TH ir TD turi būti TR antriniai elementai + TR must be a child of Table, THead, TBody, or TFoot TR turi būti Table, THead, TBody arba TFoot antrinis elementas + Tab order Tabuliavimo tvarka + Tab order is consistent with structure order Tabuliavimo tvarka atitinka struktūros tvarką + Table cells belong in a row. Fix the table's tags. Lentelės langeliai turi priklausyti eilutei. Pataisykite lentelės žymas. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Lentelės eilutės turi tiesiogiai priklausyti lentelei, jos antraštės, turinio arba poraštės daliai. Pataisykite lentelės žymas. + Tables Lentelės + Tables must contain the same number of columns in each row and rows in each column Kiekvienoje lentelės eilutėje turi būti vienodas stulpelių skaičius, o kiekviename stulpelyje — vienodas eilučių skaičius + Tables must have a summary Lentelės turi turėti santrauką + Tables should have headers Lentelės turi turėti antraštes + Tagged PDF PDF su struktūros žymomis + Tagged annotations Anotacijos su struktūros žymomis + Tagged content Turinys su struktūros žymomis + Tagged form fields Formos laukai su struktūros žymomis + Tagged multimedia Daugialypės terpės turinys su struktūros žymomis + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). Žymos suteikia dokumentui loginę struktūrą, kuria vadovaujasi pagalbinės technologijos. Pridėkite dokumento žymas („Automatiškai žymėti PDF“). + Text Field Properties Teksto lauko savybės + Text language is specified Teksto kalba nurodyta + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. Tekstas turi pakankamai išsiskirti iš fono, kad jį matytų silpnaregiai. Patikrinkite spalvas vizualiai arba kontrasto tikrinimo įrankiu. + Text recognition is already running Teksto atpažinimas jau vykdomas + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Teksto atpažinimas neįdiegtas: trūksta modelių (vykdykite `cargo xtask models` arba nustatykite PDFCRAFT_MODELS). + That didn't work: an internal error stopped it ({m}). Veiksmas nepavyko: jį sustabdė vidinė klaida ({m}). + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. Veiksmas nepavyko: jį sustabdė vidinė klaida ({m}). Dokumentai nepakeisti. + The Keychain's digital IDs couldn't be listed: {e} Nepavyko gauti raktinėje esančių skaitmeninių ID sąrašo: {e} + The Windows store's digital IDs couldn't be listed: {e} Nepavyko gauti „Windows“ saugykloje esančių skaitmeninių ID sąrašo: {e} + The clipboard has no image or text Iškarpinėje nėra paveikslėlio ar teksto + The document changed while you were choosing a file, so nothing was changed. Renkantis failą dokumentas pasikeitė, todėl pakeitimai nepritaikyti. + The document changed while you were editing field properties. Redaguojant lauko savybes dokumentas pasikeitė. + The document has additional actions (/AA) Dokumentas turi papildomų veiksmų (/AA) + The document has document-level JavaScript Dokumentas turi dokumento lygmens „JavaScript“ scenarijų + The document has no XMP metadata Dokumentas neturi XMP metaduomenų + The document is encrypted Dokumentas užšifruotas + The document is no longer open. Dokumentas jau užvertas. + The document was closed before it could be saved. Dokumentas užvertas prieš jį įrašant. + The document's security settings don't allow filling in form fields Dokumento saugos nuostatos neleidžia pildyti formos laukų + The field properties could not be changed. Nepavyko pakeisti lauko savybių. + The field properties dialog is no longer open. Lauko savybių dialogas jau užvertas. + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. Forma prašo pateikti duomenis į {u}; „PdfCraft“ formos duomenų nesiunčia. Įrašykite dokumentą, kad išsaugotumėte įvestas reikšmes. + The form asks viewers to make appearances (NeedAppearances) Forma prašo žiūryklių sukurti laukų išvaizdą (NeedAppearances) + The linked PDF opened, but its named destination was not applied. Susietas PDF atvertas, tačiau pavadinta paskirties vieta nepritaikyta. + The linked PDF opened, but its page destination was out of range. Susietas PDF atvertas, tačiau paskirties puslapis nepatenka į puslapių intervalą. + The operation failed. Veiksmas nepavyko. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. Puslapiai išskirti, bet nepašalinti, nes renkantis aplanką dokumentas pasikeitė. + The passwords don't match. Slaptažodžiai nesutampa. + The selected area has been copied ({w} × {h} pixels) Pasirinkta sritis nukopijuota ({w} × {h} pikselių) + The selected fields could not be found. Select them again. Pasirinktų laukų nerasta. Pasirinkite juos iš naujo. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standartiniai šriftai negali atvaizduoti simbolio „{c}“ ({code}). Kol kas galima įvesti tik Vakarų Europos simbolius. + The title in the metadata and the document information differ Pavadinimas metaduomenyse skiriasi nuo pavadinimo dokumento informacijoje + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. Lange turėtų būti rodomas prasmingas pavadinimas, o ne failo vardas. Nustatykite pavadinimą ir pasirinkite „Pradinis rodinys“ > „Rodyti:“ > „Dokumento pavadinimas“. + There are no redaction marks to apply Nėra pritaikytinų konfidencialios informacijos šalinimo žymų + This PDF uses the unsupported {s} action. Šis PDF naudoja nepalaikomą veiksmą {s}. + This compatibility level supports only plain ASCII passwords. Šis suderinamumo lygis palaiko tik slaptažodžius iš paprastų ASCII simbolių. + This document can't be checked: it couldn't be read for editing Dokumento negalima patikrinti: nepavyko jo perskaityti redagavimui + This document has no attachments. Šiame dokumente nėra priedų. + This document has no bookmarks. Šiame dokumente nėra žymelių. + This document has no form fields. Šiame dokumente nėra formos laukų. + This document has no layers. Šiame dokumente nėra sluoksnių. + This file is already as small as it can be made ({size}). No copy was saved. Šio failo daugiau sumažinti negalima ({size}). Kopija neįrašyta. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. Ši forma taip pat turi XFA duomenų, kurių „PdfCraft“ dar neskaito. Laukus galite pildyti, tačiau „Acrobat“ gali rodyti XFA reikšmes. + Thumbnails Miniatiūros + Timed responses Atsakymai per ribotą laiką + Type Password Įveskite slaptažodį + Type a password. Įveskite slaptažodį. + Typewriter Teksto įvedimas + Unchecked Nepažymėta + Undid {label} Atšaukta: {label} + Unlock comment Atrakinti komentarą + Unlocked {n} of {m} files Atrakinta failų: {n} iš {m} + Update background Atnaujinti foną + Update header & footer Atnaujinti antraštę ir poraštę + Update watermark Atnaujinti vandenženklį + Use {what} Naudoti {what} + Valid Galioja + Valid from Galioja nuo + Valid to Galioja iki + Validate Tikrinimas + Validity ends Galiojimo pabaiga + Validity starts Galiojimo pradžia + Vertically Vertikaliai + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. Vaizdo, garso ir kitos daugialypės terpės elementai turi turėti žymas, kad būtų perskaitomi. Pridėkite daugialypės terpės anotacijų žymas. + Viewing Peržiūrėti + Weak Silpnas + White on black Balta juodame fone + Wrote 1 file to {dir} 1 failas įrašytas į {dir} + Wrote {n} files to {dir} Į {dir} įrašyta failų: {n} + X Object Forms XObject formos + Yellow Geltona + Yellow on black Geltona juodame fone + You can pin up to {n} folders. Unpin one first. Galite prisegti iki {n} aplankų. Pirmiausia atsekite vieną. + Your name Jūsų vardas + Zip Code Pašto kodas + Zip Code + 4 Pašto kodas su 4 papildomais skaitmenimis + `{command}` {when} `{command}` · {when} + a bookmark cannot be moved inside itself žymelės negalima perkelti į ją pačią + a document must keep at least one page dokumente turi likti bent vienas puslapis + average vidurkis + maximum didžiausia reikšmė + minimum mažiausia reikšmė + no such document tokio dokumento nėra + nothing in the file matches this document faile nėra šį dokumentą atitinkančių duomenų + nothing to redo nėra ką pakartoti + nothing to undo nėra ką atšaukti + pages puslapiai + product (x) sandauga (×) + sum (+) suma (+) + the document has no form fields dokumente nėra formos laukų + the document has no page tree dokumente nėra puslapių medžio + the document has no tags dokumente nėra žymų + the document is protected by a password dokumentas apsaugotas slaptažodžiu + the image is empty paveikslėlis tuščias + the password is incorrect slaptažodis netinkamas + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) teksto atpažinimo modeliai neįdiegti (iš naujo įdiekite „PdfCraft“ arba nustatykite PDFCRAFT_MODELS aplanką, kuriame jie laikomi) + the title must not be empty pavadinimas negali būti tuščias + there are no pages to print nėra spausdintinų puslapių + there are no redaction marks to apply nėra pritaikytinų konfidencialios informacijos šalinimo žymų + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) šis dokumentas pasirašytas: perrašius parašai nebegaliotų (vietoj to įrašykite palaipsniui) + this file isn't XFDF, FDF, XML or text form data šis failas nėra XFDF, FDF, XML arba tekstiniai formos duomenys + {0}: {1} {0}: {1} + {field}: {e} Laukas {field}: {e} + {file} is too large for a stamp (at most {n} MB) Failas {file} per didelis antspaudui (leidžiama iki {n} MB) + {folder} is already pinned to Home Aplankas {folder} jau prisegtas prie pradžios rodinio + {label} failed: {e} Veiksmas {label} nepavyko: {e} + {name} has no action {name} neturi veiksmo + {name} is too large for a word list (at most 1 MB). {name} per didelis žodžių sąrašui (leidžiama iki 1 MB). + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} pateikia formą į {url}; „PdfCraft“ formos duomenų nesiunčia. Įrašykite dokumentą, kad išsaugotumėte įvestas reikšmes. + {name}: choosing images arrives on the web with file pickers for images {name}: žiniatinklio versijoje paveikslėlius bus galima pasirinkti įdiegus jų failų pasirinkimą + {name}: the {other} action isn't supported yet {name}: veiksmas {other} dar nepalaikomas + {who} in this document tried to open an address PdfCraft won't open: {e}. Šiame dokumente {who} bandė atverti adresą, kurio „PdfCraft“ neatveria: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. Šiame dokumente {who} bandė atverti adresą, kurio „PdfCraft“ neatveria: {e}. Iš dokumentų atveriami tik žiniatinklio (http, https) ir el. pašto (mailto) saitai. +animation effects Off Išjungta +animation effects On Įjungta + View more Rodyti daugiau + View less Rodyti mažiau + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Tęskite tik jei pasitikite šiuo dokumentu. Adresu gali būti perduota dokumento informacija, pavyzdžiui, tai, ką įvedėte į jo formą. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Spausdinkite vienpusiškai. Išlaikykite lapų tvarką, pjaukite ties žymomis, tada sudėkite krūveles iš kairės į dešinę ir iš viršaus į apačią. + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Pasirašykite ant balto popieriaus lapo ir nufotografuokite jį esant ryškiam, tolygiam apšvietimui (naudokite blykstę). Pasirinkite nuotrauką arba nuvilkite ją čia: „PdfCraft“ suras parašą ir pašalins popieriaus foną. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Šio lauko šriftas negali atvaizduoti simbolio „{c}“ ({code}), todėl „PdfCraft“ vietoj jo rodo ?. Reikšmė įrašoma, o kitos PDF žiūryklės pagal ją iš naujo atvaizduoja lauką. + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. Simbolių {chars} dar negalima įrašyti į PDF: pildymo ir pasirašymo tekstui palaikomi tik Vakarų Europos simboliai. Pasirinkite skaitinį formatą, pvz., dd.mm.yyyy. + January sausis + February vasaris + March kovas + April balandis + May gegužė + June birželis + July liepa + August rugpjūtis + September rugsėjis + October spalis + November lapkritis + December gruodis +calendar date January sausio +calendar date February vasario +calendar date March kovo +calendar date April balandžio +calendar date May gegužės +calendar date June birželio +calendar date July liepos +calendar date August rugpjūčio +calendar date September rugsėjo +calendar date October spalio +calendar date November lapkričio +calendar date December gruodžio +calendar weekday Su Sk +calendar weekday Mo Pr +calendar weekday Tu An +calendar weekday We Tr +calendar weekday Th Kt +calendar weekday Fr Pn +calendar weekday Sa Št +signature pad Type Tekstu +scroll speed Slow Lėtas +scroll speed Normal Įprastas +scroll speed Fast Greitas +certificate Subject Sertifikato subjektas +certificate Issued to Išduota kam +certificate Issued by Išdavė +certificate Valid from Galioja nuo +certificate Valid to Galioja iki +certificate Intended usage Naudojimo paskirtis +certificate Version Versija +certificate Serial number Serijos numeris +certificate Issuer Išdavėjas +certificate Validity starts Galiojimo pradžia +certificate Validity ends Galiojimo pabaiga +certificate Public key Viešasis raktas +certificate Basic constraints Pagrindiniai apribojimai +certificate Key usage Rakto naudojimas +certificate Self-signed Savarankiškai pasirašytas +certificate SHA-1 digest SHA-1 santrauka +certificate SHA-256 digest SHA-256 santrauka + Certificate authority Sertifikavimo įstaiga + End entity Galutinis subjektas + Float panel Atkabinti skydelį + Move to group Perkelti į grupę + Panels Skydeliai + Resize panel window Keisti skydelio lango dydį + Resize panels Keisti skydelių dydį + Grab bag Vardų rinkinys + No contributor data was built into this copy. Šioje programos kopijoje nėra duomenų apie bendraautorius. + No model credits were built into this copy. Šioje programos kopijoje nėra naudotų modelių sąrašo. + Reverse the order Apversti tvarką + Show Rodyti + Sort Rūšiuoti + Table Lentelė + The field {name} no longer exists. Lauko {name} nebėra. + {n} contributors · {c} commits Bendraautorių: {n} · pakeitimų įrašų: {c} + Username Naudotojo vardas + Display name Rodomas vardas + Real name Tikras vardas + Name (A–Z) Vardas (A–Z) + Merged PRs Sujungtos pakeitimų užklausos + PRs Pakeitimų užklausos + Commits Pakeitimų įrašai + Lines added Pridėtos eilutės + +LOC +Eil. + Lines deleted Pašalintos eilutės + −LOC −Eil. + Line delta Eilučių pokytis + ΔLOC ΔEil. + Binary assets added Pridėti dvejetainiai ištekliai + +Bin +Dvej. + Binary assets removed Pašalinti dvejetainiai ištekliai + −Bin −Dvej. + First commit Pirmas pakeitimų įrašas + First Pirmas + Last commit Paskutinis pakeitimų įrašas + Last Paskutinis + Company Įmonė + Model Modelis + Version Versija + % of all commits Visų pakeitimų įrašų dalis (%) + Lines +/− Eilutės +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: pakeitimų užklausų: {prs}, pakeitimų įrašų: {commits}, eilučių: +{added} / −{deleted} (Δ {delta}), dvejetainių išteklių: +{bin_added} / −{bin_deleted}, {first} – {last} + of {name} iš {name} + of {pages} iš {pages} + on page {label} puslapyje {label} + open to see what it will include atverkite ir peržiūrėkite turinį + ships in milestone {m} planuojama {m} etape + signature parašas + sorted ascending rūšiuota didėjimo tvarka + sorted descending rūšiuota mažėjimo tvarka + to iki + to {n} iki {n} + watermark vandenženklis + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. Metai: yyyy arba yy · mėnuo: m arba mm, pavadinimas: mmm arba mmmm · diena: d arba dd · savaitės diena: ddd arba dddd. H, h, M, s ir t yra laiko formato raidės, jų negalima naudoti atskirai: prieš raidę įrašykite {escape}, kad ji būtų rodoma kaip tekstas. + {c} of {n}{more} {c} iš {n}{more} + {dpi} pixels/inch {dpi} pikselių colyje + {kind} Properties {kind} savybės + {kind} field {kind} laukas + {kind} · page {p} {kind} · puslapis {p} + {kind} — click to show it on the page {kind} — spustelėkite, kad būtų parodyta puslapyje + {k} of {n} {k} iš {n} + {label} (1 issue) {label} (1 problema) + {label} ({n} issues) {label} (problemų: {n}) + {month} {day}, {y} {y} m. {month} {day} d. + {month} {y} {y} m. {month} + {msg} to {dir} {msg} į {dir} + {name} (default) {name} (numatytasis) + {name} can't be combined: {why} {name} nepavyko sujungti: {why} + {name} is read-only {name} skirtas tik skaityti + {name} — unsaved changes {name} — neįrašyti pakeitimai + {n} days ago Prieš {n} d. + {n} differences found Rasta skirtumų: {n} + {n} fields selected Pažymėta laukų: {n} + {n} files Failų: {n} + {n} files done Apdorota failų: {n} + {n} files need attention Dėmesio reikia failams: {n} + {n} h ago Prieš {n} val. + {n} instances Egzempliorių: {n} + {n} issues Problemų: {n} + {n} marks Žymų: {n} + {n} match(es) marked for redaction. Šalinimui pažymėta atitikmenų: {n}. + {n} measurements use an unsupported format and are left out. Neįtraukti {n} matavimai, nes jų formatas nepalaikomas. + {n} min ago Prieš {n} min. + {n} pages Puslapių: {n} + {n} pages selected. Pažymėta puslapių: {n}. + {n} selected Pažymėta: {n} + {n} tags Žymų: {n} + {n} word(s) or phrase(s) Žodžių ar frazių: {n} + {n} {kind} Kiekis: {n} · {kind} + {rule} - {status} Taisyklė {rule} – būsena {status} + {s} action Veiksmų: {s} + {verb} Background {verb} foną + {verb} Header and Footer {verb} antraštę ir poraštę + {verb} Watermark {verb} vandenženklį + {verb} as "{name}" {verb} kaip „{name}“ + {who} in this document wants to open this address in your browser: Šiame dokumente {who} prašo atverti šį adresą naršyklėje: + {who} in this document wants to open your email app with this address: Šiame dokumente {who} prašo atverti el. pašto programą šiuo adresu: + {words} on {pages} {words} puslapiuose {pages} + — (protection is applied when you save) — (apsauga pritaikoma įrašant) + “{name}” has security restrictions. Enter its permissions password to lift them. „{name}“ taikomi saugos apribojimai. Norėdami juos panaikinti, įveskite teisių slaptažodį. + “{name}” is protected. Enter a password to open it. „{name}“ apsaugotas. Norėdami jį atverti, įveskite slaptažodį. +@plural {n} page|{n} pages {n} puslapis|{n} puslapiai|{n} puslapių +@plural {n} file|{n} files {n} failas|{n} failai|{n} failų +@plural {n} field|{n} fields {n} laukas|{n} laukai|{n} laukų +@plural {n} comment|{n} comments {n} komentaras|{n} komentarai|{n} komentarų +@plural {n} result|{n} results {n} rezultatas|{n} rezultatai|{n} rezultatų +@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} lauko pavadinimas sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadintas: {list}|{n} laukų pavadinimai sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinti: {list}|{n} laukų pavadinimų sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinta: {list} +@plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų +@plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space Tarpas + Minimize Sumažinti + Maximize Išdidinti + Restore Atkurti + Use system title bar Naudoti sistemos pavadinimo juostą + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Rodyti paprastą pavadinimo juostą su lango mygtukais vietoj kortelių juostos. GNOME nepiešia programoms pavadinimo juostų, todėl ši neatitinka jo temos. + +# Absolute local GoToR confirmation (#1364). + Allow Leisti + Only allow this if you trust the source document. Leiskite tai tik tuo atveju, jei pasitikite pirminiu dokumentu. + Open this local PDF? Atverti šį vietinį PDF failą? + This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: + Move tag earlier Perkelti žymą anksčiau + Move tag later Perkelti žymą vėliau diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ba6be0f838..18a0fc54bf 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -58,6 +58,17 @@ fn plural_none(_: u64) -> usize { 0 } +/// Lithuanian: one (ending in 1 except 11), few (ending in 2–9 except 12–19), other otherwise. +fn plural_lithuanian(n: u64) -> usize { + match (n % 10, n % 100) { + (1, 11) => 2, + (1, _) => 0, + (2..=9, 12..=19) => 2, + (2..=9, _) => 1, + _ => 2, + } +} + /// Czech: 1 → one, 2–4 → few, everything else (0, 5+) → other. fn plural_cs(n: u64) -> usize { match n { @@ -136,7 +147,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -194,6 +205,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, // Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here. LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, + // Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other). + LangInfo { code: "lt", name: "Lietuvių", source: include_str!("lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -851,6 +864,11 @@ mod tests { } fn ui_literals() -> std::collections::BTreeSet { + scan_ui_literals(false) + } + + // Lithuanian also checks direct lookup calls and multiline literal arguments. + fn scan_ui_literals(expanded: bool) -> std::collections::BTreeSet { let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut literals = std::collections::BTreeSet::new(); while let Some(dir) = stack.pop() { @@ -862,8 +880,19 @@ mod tests { } } else if path.extension().is_some_and(|ext| ext == "rs") { let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); + let source = if expanded { source.replace("crate::i18n::t(", "tl!(") } else { source }; let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { + while let Some((_, after)) = rest.split_once(if expanded { "tl!(" } else { "tl!(\"" }) { + let after = if expanded { + let after = after.trim_start(); + let Some(after) = after.strip_prefix('"') else { + rest = after; + continue; + }; + after + } else { + after + }; let mut escaped = false; let end = after .char_indices() @@ -876,7 +905,10 @@ mod tests { }) .expect("closed tl! literal"); let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { + let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); + let literal_call = + argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); + if tail.starts_with("\")") || (expanded && literal_call) { let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); literals.insert(label); } @@ -888,6 +920,24 @@ mod tests { literals } + /// Partial catalogs also translate every label of the absolute local GoToR consent prompt. + #[test] + fn local_gotor_confirmation_is_translated_in_all_supported_languages() { + for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { + let lang = Lang::from_code(language.code).expect("registered language"); + for label in [ + "Open this local PDF?", + "This document is trying to open a local file:", + "Only allow this if you trust the source document.", + "Allow", + "Cancel", + ] { + assert!(has(lang, label), "{} lacks GoToR confirmation label: {label}", language.code); + assert_ne!(tr(lang, label), label, "{} falls back to English for {label}", language.code); + } + } + } + /// Japanese translates every `tl!("…")` literal in the UI source, so a new string can't ship in /// English by accident (the same check French, German, Russian and Simplified Chinese have). #[test] @@ -2005,6 +2055,210 @@ mod tests { set_current(Lang::EN); } + #[test] + fn lithuanian_is_registered_and_persists() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(lt.name(), "Lietuvių"); + assert_eq!(normalize_pref("LT"), Some("lt")); + for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { + assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); + } + assert_eq!(tr(lt, "File"), "Failas"); + assert_eq!(tr(lt, "Edit"), "Redaguoti"); + assert_eq!(tr(lt, "Save"), "Įrašyti"); + assert_eq!(tr(lt, "Interface language"), "Sąsajos kalba"); + assert_eq!(trn(lt, 1, "{n} page", "{n} pages"), "1 puslapis"); + assert_eq!(trn(lt, 2, "{n} page", "{n} pages"), "2 puslapiai"); + assert_eq!(trn(lt, 10, "{n} page", "{n} pages"), "10 puslapių"); + assert_eq!(trn(lt, 11, "{n} page", "{n} pages"), "11 puslapių"); + assert_eq!(trn(lt, 21, "{n} page", "{n} pages"), "21 puslapis"); + assert_eq!(trn(lt, 22, "{n} page", "{n} pages"), "22 puslapiai"); + assert_eq!(trn(lt, 12, "{n} object selected", "{n} objects selected"), "Pažymėta 12 objektų"); + assert_eq!(trn(lt, 22, "{n} object selected", "{n} objects selected"), "Pažymėti 22 objektai"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "LT").unwrap(); + assert_eq!(app.language, "lt"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "lt"); + assert!(restored.set_option("language", "xx").is_err()); + } + + #[test] + fn lithuanian_covers_commands_and_catalogue() { + let lt = Lang::from_code("lt").expect("lt registered"); + let mut missing = std::collections::BTreeSet::new(); + for command in pdfcraft_engine::commands::COMMANDS { + if !has(lt, command.label) { + missing.insert(command.label); + } + if let Some(menu) = command.menu + && !has(lt, menu) + { + missing.insert(menu); + } + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + if !has(lt, group.label) { + missing.insert(group.label); + } + for section in group.sections { + if !has(lt, section.title) { + missing.insert(section.title); + } + for item in section.items { + if !has(lt, item.label) { + missing.insert(item.label); + } + } + } + } + assert!(missing.is_empty(), "Lithuanian misses commands/tools: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_ui_literals() { + let lt = Lang::from_code("lt").expect("lt registered"); + let literals = scan_ui_literals(true); + assert!(literals.contains("Float panel"), "direct lookup literals are included"); + assert!(literals.contains("@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"), "multiline literal arguments are included"); + let missing: Vec<_> = literals.iter().filter(|label| !has(lt, label)).collect(); + assert!(missing.is_empty(), "Untranslated Lithuanian UI labels: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_contextual_calendar_certificate_and_preference_labels() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr_ctx(lt, "signature pad", "Type"), "Tekstu"); + for label in ["All tools", "Read", "E-Sign", "Tools", "View more", "View less"] { + assert!(has(lt, label), "shell label remains untranslated: {label}"); + } + assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(lt, "scroll speed", label)), ["Lėtas", "Įprastas", "Greitas"]); + let months = [ + ("January", "sausis", "sausio"), + ("February", "vasaris", "vasario"), + ("March", "kovas", "kovo"), + ("April", "balandis", "balandžio"), + ("May", "gegužė", "gegužės"), + ("June", "birželis", "birželio"), + ("July", "liepa", "liepos"), + ("August", "rugpjūtis", "rugpjūčio"), + ("September", "rugsėjis", "rugsėjo"), + ("October", "spalis", "spalio"), + ("November", "lapkritis", "lapkričio"), + ("December", "gruodis", "gruodžio"), + ]; + for (source, standalone, in_date) in months { + assert_eq!(tr(lt, source), standalone); + assert_eq!(tr_ctx(lt, "calendar date", source), in_date); + } + assert_eq!( + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(lt, "calendar weekday", day)), + ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] + ); + assert_eq!( + fmt(tr(lt, "{month} {day}, {y}"), &[("month", tr_ctx(lt, "calendar date", "March")), ("day", "9"), ("y", "2026")]), + "2026 m. kovo 9 d." + ); + assert_eq!(tr_ctx(lt, "certificate", "Subject"), "Sertifikato subjektas"); + for field in [ + "Issued to", + "Issued by", + "Valid from", + "Valid to", + "Intended usage", + "Serial number", + "Issuer", + "Validity starts", + "Validity ends", + "Public key", + "Basic constraints", + "Key usage", + "Self-signed", + "SHA-1 digest", + "SHA-256 digest", + ] { + assert_ne!(tr_ctx(lt, "certificate", field), field, "certificate field remains untranslated: {field}"); + } + } + + #[test] + fn lithuanian_dialog_wording_preserves_resolution_and_selection_counts() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr(lt, "for images above"), "kai raiška viršija"); + assert_eq!( + fmt(tr(lt, "Checking Options ({on} of 32 in all categories)"), &[("on", "31")]), + "Tikrinimo parinktys (visose kategorijose: 31 iš 32)" + ); + } + + #[test] + fn lithuanian_covers_bundled_catalog_keys_including_dynamic_dialog_labels() { + // Compare English keys, not other languages' translations. Literal scans alone + // miss labels passed through helpers, enum methods and conditional expressions. + let lt = Lang::from_code("lt").expect("lt registered"); + let mut missing = std::collections::BTreeSet::new(); + for reference in LANGUAGES.iter().filter(|language| language.code != "lt") { + let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); + assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); + for entry in entries { + let translated = match entry.context.as_str() { + "" => lt.catalog().plain(&entry.source), + "@id" => lt.catalog().id(&entry.source), + "@plural" => entry.source.split_once('|').and_then(|(one, other)| lt.catalog().plural(one, other, 0)), + context => lt.catalog().contextual(context, &entry.source), + }; + if translated.is_none() { + missing.insert((entry.context, entry.source)); + } + } + } + assert!(missing.is_empty(), "Lithuanian misses bundled catalog keys: {missing:#?}"); + } + + #[test] + fn lithuanian_covers_credits_labels() { + let lt = Lang::from_code("lt").expect("lt registered"); + let labels = crate::credits::NameMode::ALL + .iter() + .map(|mode| mode.label()) + .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { + let (menu, heading) = key.label(); + [menu, heading] + })) + .chain(crate::credits::MODEL_COLUMNS.iter().copied()); + let missing: Vec<_> = labels.filter(|label| !has(lt, label)).collect(); + assert!(missing.is_empty(), "Lithuanian misses credits labels: {missing:#?}"); + } + + #[test] + fn lithuanian_wording_fixes_keep_context_and_meaning() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(tr(lt, "of {name}"), "iš {name}"); + assert_eq!(tr(lt, "Active"), "Aktyvi", "the adjective describes a theme (tema)"); + for n in [1, 2, 12] { + let renamed = trn( + lt, + n, + "{n} field name collides with earlier files and will be renamed: {list}", + "{n} field names collide with earlier files and will be renamed: {list}", + ); + assert!(renamed.contains("ankstesniuose failuose esančių laukų pavadinimais")); + assert!(renamed.contains("bus pervadint")); + assert!(renamed.contains("{list}")); + } + assert_eq!(tr(lt, "Two-Up (Facing)"), "Du atgręžti puslapiai"); + assert_eq!(tr(lt, "Windows store · "), "„Windows“ sertifikatų saugykla · "); + assert_eq!( + tr(lt, "Adds invisible text over each word; the page image is not changed"), + "Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia" + ); + assert!( + tr(lt, "The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.") + .contains("visose PDF žiūryklėse") + ); + } + #[test] fn slovene_is_registered_and_persists() { let sl = Lang::from_code("sl").expect("sl registered"); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ee205f5646..310a990f7d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -847,6 +847,7 @@ , Expires: , Wygasa: No digital IDs yet. Brak identyfikatorów cyfrowych. Configure New Digital ID Skonfiguruj nowy identyfikator cyfrowy + Remove "{name}" from this list? The file itself is not deleted. Usunąć „{name}” z tej listy? Sam plik nie zostanie usunięty. Continue Kontynuuj Configure a Digital ID for Signing Skonfiguruj identyfikator cyfrowy do podpisywania Use a Digital ID from a file Użyj identyfikatora cyfrowego z pliku @@ -2318,3 +2319,16 @@ animation effects Off Wyłączone Local cross-PDF links require the desktop app Lokalne łącza między plikami PDF wymagają aplikacji komputerowej The existing action will be preserved. Istniejąca akcja zostanie zachowana. This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skrypty tego formularza są wyłączone, ponieważ jeden z nich działał zbyt długo. Otwórz dokument ponownie, aby znów je uruchomić. + Minimize Zminimalizuj + Maximize Zmaksymalizuj + Restore Przywróć + Use system title bar Używaj systemowego paska tytułu + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Pokazuj prosty pasek tytułu z przyciskami okna zamiast paska kart. GNOME nie rysuje pasków tytułu dla aplikacji, więc ten nie pasuje do jego motywu. + +# Absolute local GoToR confirmation (#1364). + Allow Zezwól + Only allow this if you trust the source document. Zezwól na to tylko wtedy, gdy ufasz dokumentowi źródłowemu. + Open this local PDF? Otworzyć ten lokalny plik PDF? + This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: + Move tag earlier Przenieś znacznik wcześniej + Move tag later Przenieś znacznik później diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f22ad9c86c..623d69ce80 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -484,6 +484,7 @@ , Expires: , Expira em: No digital IDs yet. Nenhuma ID digital ainda. Configure New Digital ID Configurar nova ID digital + Remove "{name}" from this list? The file itself is not deleted. Remover "{name}" desta lista? O arquivo em si não é excluído. Continue Continuar Configure a Digital ID for Signing Configurar uma ID digital para assinatura Use a Digital ID from a file Usar uma ID digital de um arquivo @@ -2352,3 +2353,16 @@ animation effects Off Desativados The linked PDF opened, but its named destination was not applied. O PDF vinculado foi aberto, mas seu destino nomeado não foi aplicado. The linked PDF opened, but its page destination was out of range. O PDF vinculado foi aberto, mas sua página de destino está fora do intervalo. Local cross-PDF links require the desktop app Links locais entre PDFs exigem o aplicativo para desktop + Minimize Minimizar + Maximize Maximizar + Restore Restaurar + Use system title bar Usar a barra de título do sistema + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Mostrar uma barra de título simples para os botões da janela em vez da barra de abas. O GNOME não desenha barras de título para os aplicativos, então esta não segue o tema dele. + +# Absolute local GoToR confirmation (#1364). + Allow Permitir + Only allow this if you trust the source document. Permita esta ação somente se confiar no documento de origem. + Open this local PDF? Abrir este PDF local? + This document is trying to open a local file: Este documento está tentando abrir um arquivo local: + Move tag earlier Mover tag para antes + Move tag later Mover tag para depois diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ac054d026c..7f628088d6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -849,6 +849,7 @@ , Expires: , Истекает: No digital IDs yet. Нет цифровых идентификаторов. Configure New Digital ID Настроить новый цифровой идентификатор + Remove "{name}" from this list? The file itself is not deleted. Убрать «{name}» из этого списка? Сам файл не удаляется. Continue Продолжить Configure a Digital ID for Signing Настроить цифровой идентификатор для подписи Use a Digital ID from a file Использовать цифровой идентификатор из файла @@ -2251,3 +2252,16 @@ animation effects Off Выкл The linked PDF opened, but its named destination was not applied. Связанный PDF открыт, но его именованное назначение не применено. The linked PDF opened, but its page destination was out of range. Связанный PDF открыт, но страница назначения вне допустимого диапазона. Local cross-PDF links require the desktop app Локальные ссылки между PDF работают только в настольном приложении + Minimize Свернуть + Maximize Развернуть + Restore Восстановить + Use system title bar Использовать системный заголовок окна + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Показывать простой заголовок окна с кнопками вместо полосы вкладок. GNOME не рисует заголовки для приложений, поэтому этот заголовок не следует его теме. + +# Absolute local GoToR confirmation (#1364). + Allow Разрешить + Only allow this if you trust the source document. Разрешите это действие, только если доверяете исходному документу. + Open this local PDF? Открыть этот локальный PDF? + This document is trying to open a local file: Этот документ пытается открыть локальный файл: + Move tag earlier Переместить тег раньше + Move tag later Переместить тег позже diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index e8dc01620c..3feaad354d 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -425,6 +425,7 @@ Compress document structure (object streams) Stisni strukturo dokumenta (tokovi objektov) Compression Stiskanje Configure New Digital ID Nastavi novo digitalno identiteto + Remove "{name}" from this list? The file itself is not deleted. Odstranim »{name}« s tega seznama? Sama datoteka ne bo izbrisana. Configure a Digital ID for Signing Nastavite digitalno identiteto za podpisovanje Confirm Password Potrdite geslo Conformance level: Raven skladnosti: @@ -2343,3 +2344,16 @@ animation effects Off Izklopljeni The linked PDF opened, but its named destination was not applied. Povezani PDF se je odprl, vendar njegov poimenovani cilj ni bil uporabljen. The linked PDF opened, but its page destination was out of range. Povezani PDF se je odprl, vendar je ciljna stran zunaj obsega. Local cross-PDF links require the desktop app Lokalne povezave med PDF-ji zahtevajo namizno aplikacijo + Minimize Pomanjšaj + Maximize Povečaj + Restore Obnovi + Use system title bar Uporabi sistemsko naslovno vrstico + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Prikaži preprosto naslovno vrstico za gumbe okna namesto vrstice zavihkov. GNOME aplikacijam ne riše naslovnih vrstic, zato ta ne sledi njegovi temi. + +# Absolute local GoToR confirmation (#1364). + Allow Dovoli + Only allow this if you trust the source document. To dovolite le, če zaupate izvornemu dokumentu. + Open this local PDF? Želite odpreti ta lokalni PDF? + This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: + Move tag earlier Premakni oznako prej + Move tag later Premakni oznako pozneje diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 292b9ae886..d15365f3d4 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -828,6 +828,7 @@ comment menu Edit సవరించు , Expires: , గడువు: No digital IDs yet. ఇంకా డిజిటల్ ఐడీలు ఏవీ లేవు. Configure New Digital ID కొత్త డిజిటల్ ఐడీని కాన్ఫిగర్ చేయి + Remove "{name}" from this list? The file itself is not deleted. ఈ జాబితా నుండి "{name}"ని తీసివేయాలా? ఫైల్ తొలగించబడదు. Continue కొనసాగించు Configure a Digital ID for Signing సంతకం కోసం డిజిటల్ ఐడీని కాన్ఫిగర్ చేయి Use a Digital ID from a file ఫైల్‌లోని డిజిటల్ ఐడీని ఉపయోగించు @@ -2251,3 +2252,16 @@ animation effects Off ఆఫ్ Local cross-PDF links require the desktop app PDFల మధ్య స్థానిక లింక్‌లకు డెస్క్‌టాప్ యాప్ అవసరం The existing action will be preserved. ఉన్న చర్య అలాగే ఉంచబడుతుంది. This form's scripts are turned off because one ran too long. Reopen the document to run them again. ఒక స్క్రిప్ట్ చాలా సేపు నడిచినందున ఈ ఫారమ్ స్క్రిప్ట్‌లు ఆపివేయబడ్డాయి. వాటిని మళ్లీ నడపడానికి పత్రాన్ని మళ్లీ తెరవండి. + Minimize కనిష్ఠీకరించు + Maximize గరిష్ఠీకరించు + Restore పునరుద్ధరించు + Use system title bar సిస్టమ్ శీర్షిక పట్టీని ఉపయోగించు + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. ట్యాబ్ పట్టీకి బదులుగా విండో బటన్‌ల కోసం సాధారణ శీర్షిక పట్టీని చూపించు. GNOME యాప్‌లకు శీర్షిక పట్టీలను గీయదు, కాబట్టి ఇది దాని థీమ్‌ను అనుసరించదు. + +# Absolute local GoToR confirmation (#1364). + Allow అనుమతించు + Only allow this if you trust the source document. మూల పత్రాన్ని విశ్వసిస్తే మాత్రమే దీన్ని అనుమతించండి. + Open this local PDF? ఈ స్థానిక PDF ఫైల్‌ను తెరవాలా? + This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: + Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు + Move tag later ట్యాగ్‌ను వెనుకకు తరలించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d990dc99e..44fd05f074 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -810,6 +810,7 @@ , Expires: , Son kullanma tarihi: No digital IDs yet. Henüz dijital kimlik yok. Configure New Digital ID Yeni Dijital Kimlik Yapılandır + Remove "{name}" from this list? The file itself is not deleted. "{name}" bu listeden kaldırılsın mı? Dosyanın kendisi silinmez. Continue Devam Et Configure a Digital ID for Signing İmzalama için Dijital Kimlik Yapılandır Use a Digital ID from a file Dosyadan Dijital Kimlik Kullan @@ -2334,3 +2335,16 @@ animation effects Off Kapalı The linked PDF opened, but its named destination was not applied. Bağlantılı PDF açıldı, ancak adlandırılmış hedefi uygulanmadı. The linked PDF opened, but its page destination was out of range. Bağlantılı PDF açıldı, ancak sayfa hedefi aralık dışında. Local cross-PDF links require the desktop app PDF'ler arası yerel bağlantılar masaüstü uygulamasını gerektirir + Minimize Küçült + Maximize Büyüt + Restore Geri yükle + Use system title bar Sistem başlık çubuğunu kullan + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Sekme çubuğu yerine pencere düğmeleri için basit bir başlık çubuğu göster. GNOME uygulamalar için başlık çubuğu çizmez, bu yüzden bu çubuk onun temasına uymaz. + +# Absolute local GoToR confirmation (#1364). + Allow İzin ver + Only allow this if you trust the source document. Buna yalnızca kaynak belgeye güveniyorsanız izin verin. + Open this local PDF? Bu yerel PDF açılsın mı? + This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: + Move tag earlier Etiketi daha erken taşı + Move tag later Etiketi daha geç taşı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 89aa68d54a..648dc855fe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -853,6 +853,7 @@ , Expires: , Дійсний до: No digital IDs yet. Ще немає цифрових посвідчень. Configure New Digital ID Налаштувати нове цифрове посвідчення + Remove "{name}" from this list? The file itself is not deleted. Прибрати «{name}» з цього списку? Сам файл не видаляється. Continue Продовжити Configure a Digital ID for Signing Налаштувати цифрове посвідчення для підписування Use a Digital ID from a file Використати цифрове посвідчення з файлу @@ -2360,3 +2361,16 @@ animation effects Off Вимкнено The linked PDF opened, but its named destination was not applied. Пов’язаний PDF відкрито, але його іменоване призначення не застосовано. The linked PDF opened, but its page destination was out of range. Пов’язаний PDF відкрито, але сторінка призначення поза межами діапазону. Local cross-PDF links require the desktop app Локальні посилання між PDF працюють лише в настільному застосунку + Minimize Згорнути + Maximize Розгорнути + Restore Відновити + Use system title bar Використовувати системний заголовок вікна + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Показувати простий заголовок вікна з кнопками замість панелі вкладок. GNOME не малює заголовки для програм, тому цей заголовок не відповідає його темі. + +# Absolute local GoToR confirmation (#1364). + Allow Дозволити + Only allow this if you trust the source document. Дозволяйте це лише тоді, коли довіряєте вихідному документу. + Open this local PDF? Відкрити цей локальний PDF? + This document is trying to open a local file: Цей документ намагається відкрити локальний файл: + Move tag earlier Перемістити тег раніше + Move tag later Перемістити тег пізніше diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a1e6e25e28..391c8eda7c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -537,6 +537,7 @@ , Expires: ,有效期至: No digital IDs yet. 尚无数字 ID。 Configure New Digital ID 配置新数字 ID + Remove "{name}" from this list? The file itself is not deleted. 从此列表中移除“{name}”?文件本身不会被删除。 Continue 继续 Configure a Digital ID for Signing 配置用于签名的数字 ID Use a Digital ID from a file 使用文件中的数字 ID @@ -2302,3 +2303,16 @@ animation effects Off 关 The linked PDF opened, but its named destination was not applied. 已打开链接的 PDF,但未应用其命名目标。 The linked PDF opened, but its page destination was out of range. 已打开链接的 PDF,但其页面目标超出范围。 Local cross-PDF links require the desktop app PDF 之间的本地链接需要桌面应用 + Minimize 最小化 + Maximize 最大化 + Restore 还原 + Use system title bar 使用系统标题栏 + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. 用简单的标题栏显示窗口按钮,而不是在标签栏中。GNOME 不为应用绘制标题栏,因此此标题栏不会跟随其主题。 + +# Absolute local GoToR confirmation (#1364). + Allow 允许 + Only allow this if you trust the source document. 仅在您信任源文档时才允许此操作。 + Open this local PDF? 是否打开此本地 PDF? + This document is trying to open a local file: 此文档正尝试打开本地文件: + Move tag earlier 将标签前移 + Move tag later 将标签后移 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c9db457e30..da45921c21 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -489,6 +489,7 @@ , Expires: ,有效期至: No digital IDs yet. 尚無數位 ID。 Configure New Digital ID 配置新數位 ID + Remove "{name}" from this list? The file itself is not deleted. 從此清單中移除「{name}」?檔案本身不會被刪除。 Continue 繼續 Configure a Digital ID for Signing 設定簽署用的數位 ID Use a Digital ID from a file 使用檔案中的數位 ID @@ -2212,3 +2213,16 @@ animation effects Off 關 The linked PDF opened, but its page destination was out of range. 已開啟連結的 PDF,但其頁面目的地超出範圍。 Local cross-PDF links require the desktop app PDF 之間的本機連結需要桌面應用程式 The existing action will be preserved. 將保留現有動作。 + Minimize 最小化 + Maximize 最大化 + Restore 還原 + Use system title bar 使用系統標題列 + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. 以簡單的標題列顯示視窗按鈕,而不是在分頁列中。GNOME 不為應用程式繪製標題列,因此此標題列不會跟隨其佈景主題。 + +# Absolute local GoToR confirmation (#1364). + Allow 允許 + Only allow this if you trust the source document. 只有在您信任來源文件時,才允許此操作。 + Open this local PDF? 要開啟此本機 PDF 嗎? + This document is trying to open a local file: 此文件正嘗試開啟本機檔案: + Move tag earlier 將標記前移 + Move tag later 將標記後移 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index ef22083eab..fe474622c7 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -395,6 +395,11 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok widgets::radio_value(ui, &mut app.animation_effects, choice, tl_ctx!("animation effects", choice.label())); } }); + // Only where the app draws the title bar (GNOME on Wayland); elsewhere the desktop draws it. + if app.window_controls.is_some() { + ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar"))) + .on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme.")); + } ui.add_space(8.0); // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are // remapped while rendering, and the page clears to the background colour. The preference diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f165f2754f..ab87094886 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ pub(crate) fn date_text_for_pdf(session: &Session) -> Result { /// The longest author name kept (Preferences ▸ Identity, restored settings). pub(crate) const MAX_AUTHOR_CHARS: usize = 200; +#[cfg(not(target_arch = "wasm32"))] +mod open_worker; pub mod portable; mod protect; mod recovery; @@ -129,6 +131,10 @@ pub mod ui_scale; pub mod updates; mod wheel_pager; mod widgets; +pub mod window_controls; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod open_worker_tests; use pdfcraft_engine::{DocId, Session}; @@ -392,6 +398,8 @@ pub struct PasswordPrompt { pub name: String, pub path: Option, pub bytes: std::sync::Arc>, + #[cfg(not(target_arch = "wasm32"))] + stamp: Option, pub input: String, pub error: Option, /// A late startup file must stay in the background even after it is unlocked. @@ -408,6 +416,33 @@ pub struct PasswordPrompt { display_override: Option<(canvas::PageLayout, bool)>, } +#[cfg(not(target_arch = "wasm32"))] +struct PendingOpen { + token: u64, + receiver: std::sync::mpsc::Receiver, + name: String, + path: Option, + stamp: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, +} + +#[cfg(not(target_arch = "wasm32"))] +struct OpenRequest { + name: String, + path: Option, + stamp: Option, + input: open_worker::Input, + password: Option, + activate: bool, + after_open: Option, + recovered: Option, + then: Option<&'static str>, +} + /// Asks for the permissions password of an open document whose security restricts it, which /// lifts the restrictions (#995). Acrobat asks for it when such a document's security is changed. pub struct UnlockPrompt { @@ -465,6 +500,9 @@ pub struct PdfCraftApp { /// The user closed the Comments panel, so picking a comment tool leaves it closed until they /// open it again (#225). Remembered across restarts. pub comments_panel_closed: bool, + /// The user closed the right panel, so opening a document leaves it closed instead of + /// showing its comments or bookmarks (#1590). Remembered across restarts. + pub right_panel_closed: bool, pub quick_tool: QuickTool, /// The tool to go back to when Space, held for a temporary Hand, is released. space_hand: Option, @@ -526,11 +564,33 @@ pub struct PdfCraftApp { /// Open documents' files, watched for changes on disk. #[cfg(not(target_arch = "wasm32"))] pub(crate) watch: file_watch::FileWatch, + /// One native worker prepares opened documents without owning app or session state. + #[cfg(not(target_arch = "wasm32"))] + open_worker: Option, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec, + #[cfg(not(target_arch = "wasm32"))] + next_open_token: u64, + /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs. + #[cfg(not(target_arch = "wasm32"))] + launch_layout: Option<(canvas::PageLayout, bool)>, + #[cfg(not(target_arch = "wasm32"))] + launch_display_target: Option, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, /// Whether the macOS title bar is drawn by us (traffic lights over our tab strip). pub integrated_titlebar: bool, + /// Set when the desktop leaves the window frame to the app (GNOME on Wayland): the tab strip + /// carries these window buttons and the window's edges resize it. + pub window_controls: Option, + /// Preferences ▸ Use system title bar: where `window_controls` is set, give the window back + /// winit's frame instead of drawing the header bar (#1001). + pub system_title_bar: bool, + /// Whether the window currently has the app's header bar (`None` until the first frame). + app_frame_shown: Option, pub password_prompt: Option, pub unlock_prompt: Option, pub full_screen: bool, @@ -604,7 +664,8 @@ pub struct PdfCraftApp { pub doc_js: js_ui::DocJsDraft, pub a11y: a11y_ui::A11yState, pub(crate) tags_state: tags_ui::State, - pub a11y_skipped: std::collections::BTreeSet, + /// Rules skipped by hand, for the document they were skipped in. + pub a11y_skipped: std::collections::HashSet<(DocId, pdfcraft_engine::a11y::Rule)>, pub alt_draft: a11y_ui::AltDraft, /// List the OS key store's signing identities among the digital IDs (the desktop app). pub os_key_store_ids: bool, @@ -727,6 +788,8 @@ pub struct PdfCraftApp { pub last_opened_url: Option, /// A document asked to open this address; the user hasn't answered yet (#90, #91). pub pending_link: Option, + /// An absolute GoToR target waiting for approval, before any target-file I/O (#1364). + pub pending_remote_link: Option, } /// Where in a document a request to open an address came from. @@ -757,6 +820,14 @@ pub struct PendingLink { pub origin: LinkOrigin, } +/// A resolved local target and its original source/destination, independent of the active tab. +#[derive(Clone, Debug)] +pub struct PendingRemoteLink { + pub source: DocId, + pub path: std::path::PathBuf, + pub destination: pdfcraft_render::RemoteDestination, +} + /// Settings for the Number pages dialog. #[derive(Clone, Debug, PartialEq)] pub struct NumberDraft { @@ -812,6 +883,7 @@ impl PdfCraftApp { right: None, right_before_read: None, comments_panel_closed: false, + right_panel_closed: false, quick_tool: QuickTool::Select, space_hand: None, comment_prefs: Default::default(), @@ -846,9 +918,24 @@ impl PdfCraftApp { reload_changed_files: true, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + open_worker: None, + #[cfg(not(target_arch = "wasm32"))] + pending_opens: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + next_open_token: 0, + #[cfg(not(target_arch = "wasm32"))] + launch_layout: None, + #[cfg(not(target_arch = "wasm32"))] + launch_display_target: None, + #[cfg(all(test, not(target_arch = "wasm32")))] + open_prepare_gate: None, pinned: Default::default(), toast: None, integrated_titlebar: false, + window_controls: None, + system_title_bar: false, + app_frame_shown: None, password_prompt: None, unlock_prompt: None, full_screen: false, @@ -937,6 +1024,7 @@ impl PdfCraftApp { bookmark_rename: None, last_opened_url: None, pending_link: None, + pending_remote_link: None, protect_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), @@ -1034,6 +1122,128 @@ impl PdfCraftApp { self.try_open(name, path, std::sync::Arc::new(bytes), None, activate) } + #[cfg(not(target_arch = "wasm32"))] + fn schedule_open(&mut self, request: OpenRequest) -> Result { + if self.open_worker.is_none() { + self.open_worker = Some( + open_worker::OpenWorker::new( + #[cfg(test)] + self.open_prepare_gate.clone(), + ) + .map_err(|e| format!("couldn't start the document-open worker: {e}"))?, + ); + } + let token = self.next_open_token.wrapping_add(1); + let worker = self.open_worker.as_ref().ok_or("the document-open worker is unavailable")?; + if let Some(ctx) = &self.ctx { + worker.set_context(ctx); + } + let receiver = + worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?; + self.next_open_token = token; + self.pending_opens.push(PendingOpen { + token, + receiver, + name: request.name, + path: request.path, + stamp: request.stamp, + activate: request.activate, + after_open: request.after_open, + recovered: request.recovered, + then: request.then, + display_override: None, + }); + Ok(token) + } + + #[cfg(not(target_arch = "wasm32"))] + fn poll_pending_opens(&mut self) { + let mut ready = Vec::new(); + let mut waiting = Vec::new(); + let pending_opens = std::mem::take(&mut self.pending_opens); + for pending in pending_opens { + match pending.receiver.try_recv() { + Ok(result) if result.token == pending.token => ready.push((pending, result.kind)), + Ok(_) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending), + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]); + } + } + } + self.pending_opens = waiting; + for (pending, kind) in ready { + self.finish_pending_open(pending, kind); + } + self.settle_launch_layout(); + } + + #[cfg(not(target_arch = "wasm32"))] + fn finish_pending_open(&mut self, pending: PendingOpen, kind: open_worker::ResultKind) { + match kind { + open_worker::ResultKind::ReadFailed(reason) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::RemoteFailed(reason) => { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &reason)]); + } + open_worker::ResultKind::RecoveryFailed(reason) => { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &pending.name), ("e", &reason)]); + } + open_worker::ResultKind::Failed(reason) => { + self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); + } + open_worker::ResultKind::Conversion(bytes) => { + if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + } + } + open_worker::ResultKind::Prepared { bytes, result } => match *result { + Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { + Ok(id) => { + if pending.path.is_some() { + self.watch.known_as(id, pending.stamp); + } + if let Some(destination) = pending.after_open { + self.apply_remote_destination(destination); + } + if let Some((layout, continuous)) = pending.display_override + && let Some(view) = self.views.iter_mut().find(|view| view.id == id) + { + view.set_display(layout, continuous); + self.launch_display_target = Some(id); + } + if let Some(meta) = &pending.recovered { + self.finish_recovery(meta); + } + if let Some(command) = pending.then { + self.execute(command); + } + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + }, + Err(error @ (pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) => { + let password_error = + matches!(error, pdfcraft_render::OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + self.password_prompt = Some(PasswordPrompt { + name: pending.name, + path: pending.path, + bytes, + stamp: pending.stamp, + input: String::new(), + error: password_error, + activate: pending.activate, + recovered: pending.recovered, + then: pending.then, + after_open: pending.after_open, + display_override: pending.display_override, + }); + } + Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]), + }, + } + } + fn try_open( &mut self, name: &str, @@ -1043,15 +1253,18 @@ impl PdfCraftApp { activate: bool, ) -> Result<(), String> { use pdfcraft_render::OpenError; - let size = bytes.len(); - let id = match self.session.open(name, path.clone(), bytes.clone(), password) { - Ok(id) => id, + let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) { + Ok(prepared) => prepared, Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); + #[cfg(not(target_arch = "wasm32"))] + let stamp = path.as_deref().and_then(file_watch::Stamp::at); self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, + #[cfg(not(target_arch = "wasm32"))] + stamp, input: String::new(), error, activate, @@ -1064,6 +1277,18 @@ impl PdfCraftApp { } Err(e) => return Err(e.to_string()), }; + self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) + } + + fn finish_open( + &mut self, + name: &str, + path: Option, + size: usize, + prepared: pdfcraft_engine::PreparedDocument, + activate: bool, + ) -> Result { + let id = self.session.commit_open(prepared); self.password_prompt = None; // The reading-colours preference is app-wide: a newly opened document starts with it. // Idempotent: the engine returns `false` when the document has the setting already. @@ -1071,9 +1296,10 @@ impl PdfCraftApp { let doc = self.session.get(id).ok_or("the document could not be opened")?; let pages = doc.info.pages.len(); // Acrobat opens straight to the Comments panel when a document has comments. - // Read mode opens with the panes closed; the rail still opens them (#932). + // Read mode opens with the panes closed; the rail still opens them (#932). A right panel + // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); - if activate && self.right.is_none() && opening_mode != Mode::Read { + if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) } else if !doc.info.outline.is_empty() { @@ -1118,7 +1344,7 @@ impl PdfCraftApp { let out = self.session.take_js_output(id); self.handle_js(id, out); } - Ok(()) + Ok(id) } #[cfg(not(target_arch = "wasm32"))] @@ -1131,8 +1357,23 @@ impl PdfCraftApp { 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_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: None, + stamp: None, + input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } + } else if let Err(error) = self.open_bytes(&name, None, bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), @@ -1234,18 +1475,42 @@ impl PdfCraftApp { } (Ok(bytes), false) => { #[cfg(not(target_arch = "wasm32"))] - self.ask_one(pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(&att.name)), None, move |app, path| { - match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { + self.ask_one( + pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), + None, + move |app, path| match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { Ok(()) => app.notify_fmt("Saved {name}", &[("name", &path.display().to_string())]), Err(e) => app.notify_fmt("Couldn't save: {e}", &[("e", &e.to_string())]), - } - }); + }, + ); #[cfg(target_arch = "wasm32")] self.notify_fmt("Downloading attachments on the web arrives with M3.10 ({n} bytes ready)", &[("n", &bytes.len().to_string())]); } } } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves + /// the frame to the app and Use system title bar is off. + pub fn draws_window_frame(&self) -> bool { + self.window_controls.is_some() && !self.system_title_bar + } + + /// Follow Use system title bar at once: the window starts without winit's frame wherever the + /// app may draw its own, and gets it back (or loses it again) when the preference changes. + fn sync_window_frame(&mut self, ctx: &egui::Context) { + if self.window_controls.is_none() { + return; + } + let app_frame = self.draws_window_frame(); + if self.app_frame_shown != Some(app_frame) { + // The window opens undecorated, so only a change from that needs a command. + if self.app_frame_shown.is_some() || !app_frame { + ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(!app_frame)); + } + self.app_frame_shown = Some(app_frame); + } + } + /// 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; @@ -1261,13 +1526,52 @@ impl PdfCraftApp { if let Some(meta) = p.recovered { self.recoverable.push(meta); } + #[cfg(not(target_arch = "wasm32"))] + self.settle_launch_layout(); return; }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + let result = self.schedule_open(OpenRequest { + name: p.name.clone(), + path: p.path.clone(), + stamp: p.stamp, + input: open_worker::Input::Bytes(p.bytes.clone()), + password: Some(pw), + activate: p.activate, + after_open: p.after_open.clone(), + recovered: p.recovered.clone(), + then: p.then, + }); + if let Ok(token) = &result + && let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == *token) + { + pending.display_override = p.display_override; + } + if let Err(error) = result { + let mut prompt = p; + prompt.error = Some(error); + self.password_prompt = Some(prompt); + } + return; + } + #[cfg(not(target_arch = "wasm32"))] + let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp); let after_open = p.after_open.clone(); match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once its prompt is gone. Ok(()) if self.password_prompt.is_none() => { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = path_for_watch.as_deref() + && let Some(id) = self + .views + .iter() + .rev() + .find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id))) + { + self.watch.known_as(id, stamp_for_watch); + } if let Some(destination) = after_open { self.apply_remote_destination(destination); } @@ -1275,6 +1579,10 @@ impl PdfCraftApp { && let Some(view) = self.views.last_mut() { view.set_display(layout, continuous); + #[cfg(not(target_arch = "wasm32"))] + { + self.launch_display_target = Some(view.id); + } } if let Some(meta) = &p.recovered { self.finish_recovery(meta); @@ -1294,6 +1602,8 @@ impl PdfCraftApp { } } } + #[cfg(not(target_arch = "wasm32"))] + self.settle_launch_layout(); } /// Ask for the active document's permissions password, then run `then` (if any). @@ -1336,7 +1646,11 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn open_remote_link(&mut self, source: DocId, file: String, destination: pdfcraft_render::RemoteDestination) { - let source_path = self.session.get(source).and_then(|doc| doc.path.clone()); + if self.pending_remote_link.is_some() || self.pending_link.is_some() { + return; + } + let Some(source_doc) = self.session.get(source) else { return }; + let source_path = source_doc.path.clone(); let path = match pdfcraft_engine::resolve_remote_document_path(source_path.as_deref().map(std::path::Path::new), &file) { Ok(path) => path, Err(e) => { @@ -1344,27 +1658,43 @@ impl PdfCraftApp { return; } }; + if pdfcraft_engine::remote_document_path_is_absolute(&file) { + self.pending_remote_link = Some(PendingRemoteLink { source, path, destination }); + return; + } + self.open_resolved_remote_link(path, destination); + } + + /// Take a pending request exactly once. Closing its source invalidates the request. + #[cfg(not(target_arch = "wasm32"))] + pub fn resolve_pending_remote_link(&mut self, open: bool) { + if let Some(pending) = self.pending_remote_link.take() + && open + && self.session.get(pending.source).is_some() + { + self.open_resolved_remote_link(pending.path, pending.destination); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn open_resolved_remote_link(&mut self, path: std::path::PathBuf, destination: pdfcraft_render::RemoteDestination) { let path = path.to_string_lossy().into_owned(); let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone()); let stamp = file_watch::Stamp::at(&path); - let tabs = self.views.len(); self.startup_superseded = true; - let bytes = match pdfcraft_engine::read_remote_document(std::path::Path::new(&path)) { - Ok(bytes) => bytes, - Err(e) => { - self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]); - return; - } - }; - if let Err(e) = self.try_open(&name, Some(path), std::sync::Arc::new(bytes), None, true) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if let Some(prompt) = self.password_prompt.as_mut() { - prompt.after_open = Some(destination); - } else if self.views.len() > tabs { - if let Some(view) = self.views.last() { - self.watch.known_as(view.id, stamp); - } - self.apply_remote_destination(destination); + let result = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.clone()), + stamp, + input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: Some(destination), + recovered: None, + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &error)]); } } @@ -1373,6 +1703,11 @@ impl PdfCraftApp { self.notify_tr("Local cross-PDF links require the desktop app"); } + #[cfg(target_arch = "wasm32")] + pub fn resolve_pending_remote_link(&mut self, _open: bool) { + self.pending_remote_link = None; + } + fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) { let Some(index) = self.views.len().checked_sub(1) else { return }; let Some(doc) = self.session.get(self.views[index].id) else { return }; @@ -1395,18 +1730,64 @@ impl PdfCraftApp { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); - let tabs = self.views.len(); - match std::fs::read(path) { - Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - } else if self.views.len() > tabs - && let Some(doc) = self.views.last().map(|v| v.id) - { - self.watch.known_as(doc, stamp); + // Before the first frame there is no interaction to keep responsive; this also preserves + // deterministic inline startup and test flows. Once live, reading and preparation use + // the bounded worker and commit on the UI thread. + if self.ctx.is_none() || self.run_inline { + match std::fs::read(path) { + Ok(bytes) => { + let before = self.views.len(); + if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } else if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } } + Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), } - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + return; + } + self.startup_superseded = true; + if let Err(error) = self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.to_string()), + stamp, + input: open_worker::Input::Path(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: None, + recovered: None, + then: None, + }) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + } + } + + /// Apply an explicit launch layout after the command-line display overrides settle. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_launch_layout(&mut self, value: &str) -> Result<(), String> { + let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?; + self.launch_layout = Some((layout, continuous)); + self.settle_launch_layout(); + Ok(()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn settle_launch_layout(&mut self) { + if self.pending_opens.iter().any(|open| open.display_override.is_some()) + || self.password_prompt.as_ref().is_some_and(|prompt| prompt.display_override.is_some()) + { + return; + } + // Follow the existing completion/activation order, not a guessed tab index or path. + // Failed or cancelled opens cannot cause the layout to land on a restored document. + if let Some((layout, continuous)) = self.launch_layout.take() + && let Some(id) = self.launch_display_target + && let Some(view) = self.views.iter_mut().find(|view| view.id == id) + { + view.set_display(layout, continuous); } } @@ -1414,12 +1795,20 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) { let tabs = self.views.len(); + let pending = self.pending_opens.len(); self.open_path(path); if self.views.len() > tabs { if let Some(view) = self.views.last_mut() { view.set_display(layout, continuous); + self.launch_display_target = Some(view.id); } - } else if let Some(prompt) = self.password_prompt.as_mut() + } else if self.pending_opens.len() > pending + && let Some(open) = self.pending_opens.last_mut().filter(|open| open.path.as_deref() == Some(path)) + { + // The worker owns only the read; view overrides stay with this particular request. + open.display_override = Some((layout, continuous)); + } else if (self.ctx.is_none() || self.run_inline) + && let Some(prompt) = self.password_prompt.as_mut() && prompt.path.as_deref() == Some(path) { prompt.display_override = Some((layout, continuous)); @@ -1499,6 +1888,7 @@ impl PdfCraftApp { || self.password_prompt.is_some() || self.unlock_prompt.is_some() || self.pending_link.is_some() + || self.pending_remote_link.is_some() || self.updates.open } @@ -1526,7 +1916,7 @@ impl PdfCraftApp { pub fn request_document_url(&mut self, url: &str, origin: LinkOrigin) { match pdfcraft_engine::links::document_url(url) { Ok(url) => { - if self.pending_link.is_none() { + if self.pending_link.is_none() && self.pending_remote_link.is_none() { self.pending_link = Some(PendingLink { url, origin }); } } @@ -1761,6 +2151,11 @@ impl PdfCraftApp { } else if panel.is_none() && self.right == Some(RightPanel::Comments) { self.comments_panel_closed = true; } + if panel.is_some() { + self.right_panel_closed = false; + } else if self.right.is_some() { + self.right_panel_closed = true; + } self.right = panel; if panel.is_some() { self.pending_panel_reveals.push(docking::Panel::Inspector); @@ -1779,6 +2174,7 @@ impl PdfCraftApp { // Kept only while the preference is on. "last_session": self.reopen_last_session.then(|| self.session_to_save()), "reload_changed_files": self.reload_changed_files, + "system_title_bar": self.system_title_bar, "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, @@ -1827,6 +2223,7 @@ impl PdfCraftApp { "combine_view": self.combine_view.as_str(), "combine_zoom": self.combine_zoom, "comments_panel_closed": self.comments_panel_closed, + "right_panel_closed": self.right_panel_closed, }) .to_string() } @@ -1860,6 +2257,9 @@ impl PdfCraftApp { self.reopen_last_session = on; } self.last_session = last_session::LastSession::from_json(&v["last_session"]); + if let Some(on) = v["system_title_bar"].as_bool() { + self.system_title_bar = on; + } if let Some(on) = v["reload_changed_files"].as_bool() { self.reload_changed_files = on; } @@ -1888,6 +2288,9 @@ impl PdfCraftApp { if let Some(closed) = v["comments_panel_closed"].as_bool() { self.comments_panel_closed = closed; } + if let Some(closed) = v["right_panel_closed"].as_bool() { + self.right_panel_closed = closed; + } if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) { self.view_defaults = defaults; } @@ -2222,6 +2625,13 @@ impl PdfCraftApp { } ("author", _) => self.comment_prefs.author = value.to_string(), ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, + ("system-title-bar", _) => { + self.system_title_bar = match value { + "true" => true, + "false" => false, + _ => return Err("system-title-bar must be true or false".into()), + }; + } ("flatten-fill-sign", _) => { self.flatten_fill_sign_on_save = match value { "true" => true, @@ -2323,6 +2733,11 @@ impl PdfCraftApp { } } self.tool_keys(i, ctx); + if self.quick_tool == QuickTool::EditText + && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) + { + object_ui::nudge_keys(ctx, &mut self.views[i], doc); + } canvas::shortcuts(&mut self.views[i], ctx); self.copy_key(i, ctx); self.image_pick_keys(i, ctx); @@ -2420,6 +2835,13 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(worker) = &self.open_worker { + worker.set_context(ctx); + } + self.poll_pending_opens(); + } // Chinese wants its own faces before the Japanese ones (one baseline per line). let cjk = theme::CjkPreference::for_language(lang.code()); if !self.styled { @@ -2480,6 +2902,7 @@ impl eframe::App for PdfCraftApp { && self.dialog.is_none() && self.close_request.is_none() && self.pending_link.is_none() + && self.pending_remote_link.is_none() { let startup = self.startup_inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default(); for (name, bytes) in startup { @@ -2597,6 +3020,10 @@ impl eframe::App for PdfCraftApp { self.finish_render_frame(&ctx); return; } + self.sync_window_frame(&ctx); + if self.draws_window_frame() { + window_controls::resize_edges(&ctx); + } chrome::tab_strip(self, ui); chrome::mode_bar(self, ui); if self.active.is_some() { diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index feb81c8cf2..118946bd0c 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -101,6 +101,16 @@ impl ObjectSelection { } } + /// The move of the whole selection by `offset` (user space). The selection follows it. + fn move_by(&mut self, page: usize, offset: [f64; 2]) -> Edit { + let mut expected = self.selected.clone(); + for object in &mut expected { + object.rect = [object.rect[0] + offset[0], object.rect[1] + offset[1], object.rect[2] + offset[0], object.rect[3] + offset[1]]; + } + self.pending = Some(expected); + Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset } + } + fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { if self.cache.len() >= 8 && !self.cache.contains_key(&page) { self.cache.clear(); @@ -215,15 +225,7 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag } else if gesture.start.distance(gesture.now) > 3.0 && view.pending_edit.is_none() { let (a, b) = (xf.screen_to_view(gesture.start), xf.screen_to_view(gesture.now)); match doc.object_move_offset(page, [f64::from(b.0 - a.0), f64::from(b.1 - a.1)]) { - Ok(offset) => { - let mut expected = s.selected.clone(); - for object in &mut expected { - object.rect = - [object.rect[0] + offset[0], object.rect[1] + offset[1], object.rect[2] + offset[0], object.rect[3] + offset[1]]; - } - s.pending = Some(expected); - view.pending_edit = Some(Edit::MoveObjects { page, objects: s.selected.iter().map(|o| o.target).collect(), offset }); - } + Ok(offset) => view.pending_edit = Some(s.move_by(page, offset)), Err(_) => s.notice = Some(tl!("These objects could not be moved. Select them again and try a smaller move.").into()), } } @@ -294,6 +296,20 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag return true; } } + // The frame of the paragraph open for typing takes hold of the box, as in Acrobat: a click + // selects it (for the arrow keys), a drag moves it. Its text keeps the caret. + if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) + && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) + && on_frame(screen(xf, info, page, object), p) + { + s.clear(); + s.page = Some(page); + s.selected.push(object.clone()); + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + leave_single(view); + view.current = page; + return true; + } s.clear(); if hit.is_some() { return false; @@ -305,6 +321,65 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag true } +/// Whether `p` is on the frame drawn around a paragraph open for typing (`r`, screen): just +/// outside the text, where a press would otherwise close the paragraph. +fn on_frame(r: Rect, p: Pos2) -> bool { + r.expand(2.0 + FRAME_REACH).contains(p) && !r.expand(2.0).contains(p) +} + +/// How far outside a paragraph's frame a press still grabs it (screen pixels). +const FRAME_REACH: f32 = 6.0; + +/// The arrow keys move the selected image or objects a point, Shift and an arrow ten points, +/// as in Acrobat. They are taken before the view turns the page with them. +pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() { + return; + } + let image = view.image_selection.as_ref().filter(|s| !s.dragging()).map(|s| (s.page, s.index)); + let objects = view.objects.page.filter(|_| !view.objects.selected.is_empty() && view.objects.gesture.is_none()); + let Some(page) = objects.or(image.map(|(page, _)| page)) else { return }; + let Some(xf) = view.page_xform(page) else { return }; + use egui::{Key, Modifiers}; + let mut step = egui::Vec2::ZERO; + ctx.input_mut(|i| { + for (key, direction) in [ + (Key::ArrowLeft, egui::vec2(-1.0, 0.0)), + (Key::ArrowRight, egui::vec2(1.0, 0.0)), + (Key::ArrowUp, egui::vec2(0.0, -1.0)), + (Key::ArrowDown, egui::vec2(0.0, 1.0)), + ] { + // Shift first: a plain key pattern also matches a Shift press. + if i.consume_key(Modifiers::SHIFT, key) { + step += direction * 10.0; + } else if i.consume_key(Modifiers::NONE, key) { + step += direction; + } + } + }); + if step == egui::Vec2::ZERO { + return; + } + // A point on screen, whatever the zoom and the view's rotation: one screen pixel's step in + // view space, at unit length. + let c = xf.rect.center(); + let unit = |d: egui::Vec2| { + let (a, b) = (xf.screen_to_view(c), xf.screen_to_view(c + d)); + let v = egui::vec2(b.0 - a.0, b.1 - a.1); + if v.length() > 0.0 { v / v.length() } else { egui::Vec2::ZERO } + }; + let delta = unit(egui::vec2(1.0, 0.0)) * step.x + unit(egui::vec2(0.0, 1.0)) * step.y; + let Ok(offset) = doc.object_move_offset(page, [f64::from(delta.x), f64::from(delta.y)]) else { return }; + if objects.is_some() { + view.pending_edit = Some(view.objects.move_by(page, offset)); + } else if let Some((page, index)) = image + && let Some(r) = doc.page_images(page).get(index).map(|im| im.rect) + { + let rect = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Move(rect) }); + } +} + pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { if selection.page != Some(page) { return; diff --git a/crates/ui-egui/src/open_worker.rs b/crates/ui-egui/src/open_worker.rs new file mode 100644 index 0000000000..04bd1fefe5 --- /dev/null +++ b/crates/ui-egui/src/open_worker.rs @@ -0,0 +1,208 @@ +//! Native document reads and preparation run away from the frame thread. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; + +use crate::RecoveryStore; +use pdfcraft_engine::{OpenContext, PreparedDocument, SourceKind}; +use pdfcraft_render::OpenError; + +const MAX_QUEUED_OPENS: usize = 16; + +pub(crate) enum Input { + Path(PathBuf), + RemotePath(PathBuf), + Recovery(RecoveryStore, String), + Bytes(Arc>), +} + +pub(crate) enum ResultKind { + Conversion(Arc>), + Prepared { bytes: Arc>, result: Box> }, + ReadFailed(String), + RemoteFailed(String), + RecoveryFailed(String), + Failed(String), +} + +pub(crate) struct ResultMessage { + pub token: u64, + pub kind: ResultKind, +} + +struct Job { + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + reply: Sender, + #[cfg(test)] + gate: Option>, +} + +struct Queue { + jobs: VecDeque, + shutdown: bool, + repaint: Option, +} + +pub(crate) struct OpenWorker { + queue: Arc<(Mutex, Condvar)>, + #[cfg(test)] + gate: Option>, +} + +impl OpenWorker { + pub fn new(#[cfg(test)] gate: Option>) -> std::io::Result { + let queue = Arc::new((Mutex::new(Queue { jobs: VecDeque::new(), shutdown: false, repaint: None }), Condvar::new())); + let worker_queue = queue.clone(); + std::thread::Builder::new().name("pdfcraft-open".into()).spawn(move || run(worker_queue))?; + Ok(Self { + queue, + #[cfg(test)] + gate, + }) + } + + pub fn set_context(&self, ctx: &egui::Context) { + let (lock, _) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.repaint = Some(ctx.clone()); + } + + pub fn submit( + &self, + token: u64, + context: OpenContext, + name: String, + path: Option, + password: Option, + input: Input, + ) -> Result, String> { + let (reply, receiver) = mpsc::channel(); + let job = Job { + token, + context, + name, + path, + password, + input, + reply, + #[cfg(test)] + gate: self.gate.clone(), + }; + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + if queue.shutdown { + return Err("the document-open worker has stopped".into()); + } + if queue.jobs.len() >= MAX_QUEUED_OPENS { + return Err("too many documents are waiting to open".into()); + } + queue.jobs.push_back(job); + ready.notify_one(); + Ok(receiver) + } +} + +impl Drop for OpenWorker { + fn drop(&mut self) { + let (lock, ready) = &*self.queue; + let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner); + queue.shutdown = true; + queue.jobs.clear(); + queue.repaint = None; + ready.notify_one(); + } +} + +fn run(queue: Arc<(Mutex, Condvar)>) { + loop { + let job = { + let (lock, ready) = &*queue; + let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner); + while state.jobs.is_empty() && !state.shutdown { + state = ready.wait(state).unwrap_or_else(PoisonError::into_inner); + } + if state.shutdown { + return; + } + state.jobs.pop_front() + }; + let Some(job) = job else { continue }; + #[cfg(test)] + if let Some(gate) = &job.gate { + gate.pause_before_prepare(); + } + let token = job.token; + let reply = job.reply.clone(); + #[cfg(test)] + let gate = job.gate.clone(); + let kind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepare(job))) + .unwrap_or_else(|_| ResultKind::Failed("opening failed unexpectedly in the background worker".into())); + let _ = reply.send(ResultMessage { token, kind }); + let repaint = { + let (lock, _) = &*queue; + lock.lock().unwrap_or_else(PoisonError::into_inner).repaint.clone() + }; + if let Some(ctx) = repaint { + ctx.request_repaint(); + } + #[cfg(test)] + if let Some(gate) = &gate { + let _ = gate.finished.send(()); + } + } +} + +fn prepare(job: Job) -> ResultKind { + let bytes = match &job.input { + Input::Path(path) => match std::fs::read(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::ReadFailed(error.to_string()), + }, + Input::RemotePath(path) => match pdfcraft_engine::read_remote_document(path) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RemoteFailed(error.to_string()), + }, + Input::Recovery(store, key) => match store.read(key) { + Ok(bytes) => Arc::new(bytes), + Err(error) => return ResultKind::RecoveryFailed(error.to_string()), + }, + Input::Bytes(bytes) => bytes.clone(), + }; + if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) { + return ResultKind::Conversion(bytes); + } + let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref()); + ResultKind::Prepared { bytes, result: Box::new(result) } +} + +#[cfg(test)] +pub(crate) struct PrepareGate { + pub started: Sender<()>, + release: Mutex>, + pub finished: Sender<()>, +} + +#[cfg(test)] +impl PrepareGate { + pub fn pause_before_prepare(&self) { + let _ = self.started.send(()); + if let Ok(release) = self.release.lock() { + let _ = release.recv(); + } + } +} + +#[cfg(test)] +pub(crate) fn prepare_gate() -> (Arc, Receiver<()>, Sender<()>, Receiver<()>) { + let (started, started_rx) = mpsc::channel(); + let (release_tx, release) = mpsc::channel(); + let (finished, finished_rx) = mpsc::channel(); + (Arc::new(PrepareGate { started, release: Mutex::new(release), finished }), started_rx, release_tx, finished_rx) +} diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs new file mode 100644 index 0000000000..0e5775feb1 --- /dev/null +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -0,0 +1,198 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use egui_kittest::Harness; +use serde_json::{Value, json}; + +use crate::{PdfCraftApp, control::ControlClient, open_worker}; + +fn fixture(pages: usize) -> Vec { + let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; + let kids = (0..pages).map(|page| format!("{} 0 R", 4 + 2 * page)).collect::>().join(" "); + objects.push(format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 300] >>")); + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for page in 0..pages { + objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * page)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", page + 1); + objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) { + let slot: Arc>> = Arc::default(); + let saved = slot.clone(); + let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(move |creation| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").expect("language preference"); + *saved.lock().expect("control-client slot") = Some(app.attach_control(&creation.egui_ctx)); + app.open_bytes("existing.pdf", None, fixture(3)).expect("existing document"); + app + }); + harness.run_steps(3); + (harness, slot.lock().expect("control-client slot").take().expect("control client")) +} + +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn request(harness: &mut Harness<'static, PdfCraftApp>, client: &ControlClient, method: &str, params: Value) -> Value { + let reply = client.send(method, params); + for _ in 0..5 { + harness.step(); + if let Ok(result) = reply.try_recv() { + return result.expect("control request succeeded"); + } + } + panic!("{method} was not answered during the held prepare phase"); +} + +#[test] +#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { + let (mut harness, client) = harness(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + harness.state_mut().open_prepare_gate = Some(gate); + + let path = std::env::temp_dir().join(format!("pdfcraft-open-worker-{}.pdf", std::process::id())); + std::fs::write(&path, fixture(2)).expect("write test PDF"); + let open_reply = request(&mut harness, &client, "ui.open", json!({ "path": path.to_string_lossy() })); + assert_eq!(open_reply["pending"], true); + started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier"); + + let state = request(&mut harness, &client, "ui.state", json!({})); + assert_eq!(state["documents"].as_array().map(Vec::len), Some(1)); + assert_eq!(state["documents"][0]["name"], "existing.pdf"); + assert_eq!(state["pending_opens"].as_array().map(Vec::len), Some(1)); + request(&mut harness, &client, "ui.command", json!({ "id": "page.insert_blank" })); + assert_eq!(harness.state().session.get(harness.state().views[0].id).expect("existing tab").info.pages.len(), 4); + assert_eq!(harness.state().views.len(), 1, "prepared state is not visible in the session"); + + release.send(()).expect("release prepare barrier"); + finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation"); + harness.step(); + let app = harness.state(); + assert_eq!(app.views.len(), 2, "one prepared document commits exactly once"); + assert_eq!(app.session.get(app.views[1].id).expect("new tab").info.pages.len(), 2); + assert_eq!(app.active, Some(1)); + let _ = std::fs::remove_file(path); +} + +#[test] +fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() { + use crate::canvas::PageLayout; + // Controlled worker replies exercise both completion orders without scheduling races. + for reverse in [false, true] { + for count in [1, 2] { + for restored in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + if restored { + app.open_bytes("restored.pdf", None, fixture(1)).unwrap(); + } + let offset = usize::from(restored); + let mut replies = Vec::new(); + for token in 0..count { + let name = format!("cli-{token}.pdf"); + let bytes = Arc::new(fixture(2)); + let prepared = app.session.prepare_open(&name, None, bytes.clone(), None); + let (sender, receiver) = std::sync::mpsc::channel(); + app.pending_opens.push(crate::PendingOpen { + token, + receiver, + name, + path: None, + stamp: None, + activate: true, + after_open: None, + recovered: None, + then: None, + display_override: Some((PageLayout::TwoUp, false)), + }); + replies.push(( + sender, + open_worker::ResultMessage { token, kind: open_worker::ResultKind::Prepared { bytes, result: Box::new(prepared) } }, + )); + } + app.set_launch_layout("single").unwrap(); + if restored { + assert!(app.views[0].continuous(), "pending --layout must not target the restored tab"); + } + if reverse { + replies.reverse(); + } + for (index, (sender, reply)) in replies.into_iter().enumerate() { + let name = format!("cli-{}.pdf", reply.token); + sender.send(reply).unwrap(); + app.poll_pending_opens(); + let view = &app.views[index + offset]; + assert_eq!(view.layout, if index + 1 == count as usize { PageLayout::Single } else { PageLayout::TwoUp }); + assert!(!view.continuous()); + assert_eq!(app.session.get(view.id).unwrap().name, name); + } + assert_eq!(app.active, Some(count as usize + offset - 1)); + if restored { + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.views[0].continuous()); + } + assert_eq!(app.view_defaults.layout, PageLayout::Single); + assert!(app.view_defaults.continuous); + assert_eq!(serde_json::from_str::(&app.persist()).unwrap()["default_layout"], "continuous"); + // Ordinary later opens cannot inherit either launch override. + app.open_bytes("later.pdf", None, fixture(1)).unwrap(); + assert!(app.views.last().unwrap().continuous()); + } + } + } +} + +#[test] +fn launch_layout_failed_or_cancelled_opens_do_not_target_existing_documents() { + use crate::canvas::PageLayout; + for cancel in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_bytes("restored.pdf", None, fixture(1)).unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + app.pending_opens.push(crate::PendingOpen { + token: 0, + receiver, + name: "cli.pdf".into(), + path: None, + stamp: None, + activate: true, + after_open: None, + recovered: None, + then: None, + display_override: Some((PageLayout::TwoUp, false)), + }); + app.set_launch_layout("single").unwrap(); + assert!(app.set_launch_layout("invalid").is_err()); + let kind = if cancel { + open_worker::ResultKind::Prepared { bytes: Arc::new(fixture(1)), result: Box::new(Err(pdfcraft_render::OpenError::NeedsPassword)) } + } else { + open_worker::ResultKind::ReadFailed("synthetic read failure".into()) + }; + sender.send(open_worker::ResultMessage { token: 0, kind }).unwrap(); + app.poll_pending_opens(); + if cancel { + app.submit_password(None); + } + assert!(app.launch_layout.is_none()); + assert_eq!(app.views.len(), 1); + assert!(app.views[0].continuous()); + app.open_bytes("later.pdf", None, fixture(1)).unwrap(); + assert!(app.views[1].continuous()); + } +} diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index f4d0b61790..4f4197927a 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -159,15 +159,17 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { .font(theme::regular(15.0)) .desired_width(f32::INFINITY), ); - // Enter runs the top hit. The field keeps focus (requested every frame), so - // check while it is focused as well as when focus is lost. + // Enter runs the selected hit while the field is focused or relinquishing focus. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) && let Some((_, h)) = hits.get(selected) { chosen = Some((h.command, h.group)); } - r.request_focus(); + // Re-requesting existing focus interrupts native IME composition. + if !r.has_focus() { + r.request_focus(); + } }); ui.separator(); for (index, (_, h)) in hits.iter().enumerate() { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index b91a138d6f..2cd91a758a 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -148,8 +148,8 @@ impl PrintDraft { m => m, }), Handling::Multiple => match Layout::multiple(self.per_sheet) { - Layout::Multiple { cols, rows, .. } => { - Layout::Multiple { cols, rows, order: self.order, border: self.border, auto_rotate: self.auto_rotate } + Layout::Multiple { cols, rows, per_sheet, .. } => { + Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate } } other => other, }, diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 7a8b8d464e..134f54ecd7 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -199,6 +199,25 @@ impl PdfCraftApp { let Some(store) = self.recovery.clone() else { return }; for key in keys { let Some(meta) = self.recoverable.iter().find(|m| &m.key == key).cloned() else { continue }; + #[cfg(not(target_arch = "wasm32"))] + if self.ctx.is_some() && !self.run_inline { + self.startup_superseded = true; + let result = self.schedule_open(crate::OpenRequest { + name: meta.name.clone(), + path: None, + stamp: None, + input: crate::open_worker::Input::Recovery(store.clone(), key.clone()), + password: None, + activate: true, + after_open: None, + recovered: Some(meta.clone()), + then: None, + }); + if let Err(error) = result { + self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &error)]); + } + continue; + } let bytes = match store.read(key) { Ok(b) => b, Err(e) => { diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index b2f0d8cb09..14aa45160e 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -111,6 +111,8 @@ pub struct SignDraft { pub image: Option, pub new_id: NewIdDraft, pub error: Option, + /// Sign with a Digital ID ▸ Remove was clicked for this list entry: asking to confirm. + pub confirm_remove: Option, } impl SignDraft { @@ -132,6 +134,7 @@ impl SignDraft { image: None, new_id: NewIdDraft::default(), error: None, + confirm_remove: None, } } } @@ -325,6 +328,28 @@ impl PdfCraftApp { self.digital_ids.len() - 1 } + /// Take the digital ID file at `index` off the list (issue #1396). The file itself stays on + /// disk. Keychain and Windows store IDs belong to the OS and are listed again on every + /// signing, so they can't be removed here. The open dialog's selection follows the list. + pub fn remove_digital_id(&mut self, index: usize) -> bool { + if self.digital_ids.get(index).is_none_or(|e| e.path.starts_with("keychain:") || e.path.starts_with("windows:")) { + return false; + } + self.digital_ids.remove(index); + if let Some(d) = self.sign_draft.as_mut() { + d.confirm_remove = None; + d.selected = match d.selected { + Some(s) if s > index => Some(s - 1), + Some(s) if s == index => self.digital_ids.iter().position(|e| e.unusable.is_none()), + s => s, + }; + if self.digital_ids.is_empty() { + d.step = SignStep::Configure; + } + } + true + } + /// Configure New Digital ID ▸ Create: a self-signed ID saved as a .p12 next to the app data /// (or in `export_dir_override`). Returns its list index. fn create_digital_id(&mut self) -> Result { @@ -620,6 +645,7 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { } if resp.clicked() { d.selected = Some(i); + d.confirm_remove = None; } if resp.double_clicked() { d.step = SignStep::SignAs; @@ -630,11 +656,29 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { } }); ui.add_space(10.0); + // Remove takes a digital ID file off the list after asking (#1396); OS key store IDs stay. + let mut remove = None; + if let Some(e) = d.confirm_remove.and_then(|i| ids.get(i)) { + ui.label(crate::i18n::fmt(tl!("Remove \"{name}\" from this list? The file itself is not deleted."), &[("name", &e.name)])); + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Remove"), true).clicked() { + remove = d.confirm_remove; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + d.confirm_remove = None; + } + }); + ui.add_space(10.0); + } + let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:"))); ui.horizontal(|ui| { if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() { d.step = SignStep::Configure; d.error = None; } + if d.confirm_remove.is_none() && ui.add_enabled_ui(removable.is_some(), |ui| widgets::pill_button(ui, tl!("Remove"), false)).inner.clicked() { + d.confirm_remove = removable; + } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() { d.step = SignStep::SignAs; @@ -645,6 +689,9 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { } }); }); + if let Some(i) = remove { + app.remove_digital_id(i); + } close } @@ -1373,4 +1420,40 @@ mod tests { assert_eq!(mode & 0o077, 0, "the private key is not readable by others: {mode:o}"); let _ = std::fs::remove_dir_all(&dir); } + + fn listed(path: &str, name: &str) -> DigitalIdEntry { + DigitalIdEntry { path: path.into(), name: name.into(), issuer: String::new(), email: String::new(), expires: String::new(), unusable: None } + } + + /// Issue #1396: an imported ID can be taken off the list (its file stays), the dialog's + /// selection follows, an OS key store ID or a stale index is refused, and the removal is saved. + #[test] + fn a_digital_id_file_is_removed_from_the_list_and_stays_removed() { + let dir = scratch(); + let file = dir.join("old.p12"); + std::fs::write(&file, b"not opened").unwrap(); + let mut app = PdfCraftApp::new(); + app.digital_ids = vec![listed("windows:abc", "Store"), listed(&file.to_string_lossy(), "Old"), listed("/ids/new.p12", "New")]; + let mut draft = SignDraft::new(0, None, None, None, &app.digital_ids); + draft.selected = Some(2); + draft.confirm_remove = Some(1); + app.sign_draft = Some(draft); + + assert!(!app.remove_digital_id(0), "OS key store IDs stay"); + assert!(!app.remove_digital_id(9), "an index past the list is refused"); + assert!(app.remove_digital_id(1)); + assert_eq!(app.digital_ids.iter().map(|e| e.name.as_str()).collect::>(), ["Store", "New"]); + assert!(file.exists(), "the file itself is not deleted"); + let d = app.sign_draft.as_ref().unwrap(); + assert_eq!((d.selected, d.confirm_remove, d.step), (Some(1), None, SignStep::Choose), "still New, no pending question"); + + // Removing the selected ID selects the first usable one; removing the last file ID + // leaves only the store ID, which isn't saved. + assert!(app.remove_digital_id(1)); + assert_eq!(app.sign_draft.as_ref().unwrap().selected, Some(0)); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&app.persist()); + assert!(fresh.digital_ids.is_empty(), "the removal survives a restart: {:?}", fresh.digital_ids); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/standards_ui.rs b/crates/ui-egui/src/standards_ui.rs index 55f6434994..1d409f2007 100644 --- a/crates/ui-egui/src/standards_ui.rs +++ b/crates/ui-egui/src/standards_ui.rs @@ -50,7 +50,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool ui.label(egui::RichText::new("PDF/A").font(theme::semibold(18.0))); ui.add_space(6.0); let declared = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.standards()).unwrap_or_default(); - let shown = declared.pdfa.as_ref().map_or(tl!("none").to_string(), |(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())); + let mut standards: Vec = declared.pdfa.iter().map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())).collect(); + standards.extend(declared.pdfua.map(|p| format!("PDF/UA-{p}"))); + let shown = if standards.is_empty() { tl!("none").to_string() } else { standards.join(", ") }; ui.label(crate::i18n::fmt(tl!("Declared conformance: {shown}"), &[("shown", &shown)])); if !declared.output_intents.is_empty() { ui.label( diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 3a44e03cd1..3083fc5f05 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -3,7 +3,8 @@ //! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one //! only draws characters none of them has, such as an Arabic file name in a build without //! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A -//! second installed face, [`devanagari_fallback`], draws Hindi when one is installed. It is +//! second and a third installed face, [`devanagari_fallback`] and [`telugu_fallback`], draw Hindi +//! and Telugu when one is installed. It is //! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0` //! turns it off (published screenshots do). @@ -22,6 +23,8 @@ const MAX_FACES: u32 = 16; const PROBE: char = '\u{0627}'; /// Devanagari letter ka: the face must have it to cover Hindi. const DEVANAGARI_PROBE: char = '\u{0915}'; +/// Telugu letter ka: the face must have it to cover Telugu. +const TELUGU_PROBE: char = '\u{0C15}'; /// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces. const HANS_PROBE: char = '\u{6B22}'; /// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces. @@ -69,14 +72,21 @@ fn load(need: Need) -> Option> { /// or no candidate is installed; the interface then draws what the embedded faces can. pub fn devanagari_fallback() -> Option> { static CACHE: OnceLock>> = OnceLock::new(); - CACHE - .get_or_init(|| { - if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { - return None; - } - devanagari_candidates().iter().find_map(|path| read(path, DEVANAGARI_PROBE, MAX_BYTES)) - }) - .clone() + CACHE.get_or_init(|| script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE)).clone() +} + +/// The installed fallback face for Telugu, read once. `None` when it is turned off or no +/// candidate is installed; the interface then draws what the embedded faces can. +pub fn telugu_fallback() -> Option> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| script_fallback(&telugu_candidates(), TELUGU_PROBE)).clone() +} + +fn script_fallback(candidates: &[PathBuf], probe: char) -> Option> { + if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { + return None; + } + candidates.iter().find_map(|path| read(path, probe, MAX_BYTES)) } /// Well-known locations of Devanagari (Hindi) faces, best first. @@ -114,6 +124,37 @@ fn devanagari_candidates() -> Vec { } } +/// Well-known locations of Telugu faces, best first. +fn telugu_candidates() -> Vec { + if cfg!(windows) { + ["Nirmala.ttc", "Nirmala.ttf", "gautami.ttf"].iter().map(|f| windows_fonts().join(f)).collect() + } else if cfg!(target_os = "macos") { + [ + "/System/Library/Fonts/KohinoorTelugu.ttc", + "/System/Library/Fonts/Supplemental/Telugu Sangam MN.ttc", + "/System/Library/Fonts/Supplemental/Telugu MN.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ] + .iter() + .map(PathBuf::from) + .collect() + } else { + unix_fonts(&[ + "google-noto/NotoSansTeluguUI-Regular.ttf", + "google-noto/NotoSansTelugu-Regular.ttf", + "google-noto-vf/NotoSansTelugu[wght].ttf", + "google-noto-vf/NotoSansTelugu-UI[wght].ttf", + "truetype/noto/NotoSansTelugu-Regular.ttf", + "truetype/noto/NotoSansTeluguUI-Regular.ttf", + "noto/NotoSansTelugu-Regular.ttf", + "noto/NotoSansTeluguUI-Regular.ttf", + "lohit-telugu-fonts/Lohit-Telugu.ttf", + "truetype/lohit-telugu/Lohit-Telugu.ttf", + "truetype/fonts-telu-extra/Pothana2000.ttf", + ]) + } +} + /// Well-known locations of faces with broad script coverage, best first. fn candidates(need: Need) -> Vec { match need { @@ -244,6 +285,24 @@ mod tests { } } + #[test] + fn a_script_fallback_is_the_first_candidate_with_the_probe() { + let inter = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf")); + let candidates = [PathBuf::from("definitely/not/here.ttf"), inter]; + assert!(script_fallback(&candidates, 'A').is_some()); + assert!(script_fallback(&candidates, TELUGU_PROBE).is_none()); + assert!(script_fallback(&candidates, DEVANAGARI_PROBE).is_none()); + } + + #[test] + fn script_candidates_are_absolute_font_files() { + for list in [devanagari_candidates(), telugu_candidates()] { + assert!(!list.is_empty()); + assert!(list.iter().all(|p| p.is_absolute() || cfg!(windows)), "{list:?}"); + assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")), "{list:?}"); + } + } + #[test] fn han_candidates_skip_adobe_designs() { for need in [Need::Hans, Need::Hant] { diff --git a/crates/ui-egui/src/tags_ui.rs b/crates/ui-egui/src/tags_ui.rs index 718c3f8b38..2fe917f7c3 100644 --- a/crates/ui-egui/src/tags_ui.rs +++ b/crates/ui-egui/src/tags_ui.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use egui::Ui; use pdfcraft_cos::ObjRef; -use pdfcraft_engine::a11y::{TagInfo, TagProperty}; +use pdfcraft_engine::a11y::{TagInfo, TagPlacement, TagProperty}; use pdfcraft_engine::{DocId, Edit}; use crate::{icons, theme::Tokens, widgets}; @@ -181,13 +181,33 @@ pub(crate) fn panel(ui: &mut Ui, t: &Tokens, doc: DocId, tags: &[TagInfo], state }); let edit = state.draft.edit(tag); let mut save = false; + let siblings: Vec<&TagInfo> = tag.parent.map(|parent| tags.iter().filter(|other| other.parent == Some(parent)).collect()).unwrap_or_default(); + let position = siblings.iter().position(|sibling| sibling.object == tag.object); + let before = position.and_then(|position| position.checked_sub(1)).map(|position| siblings[position]); + let after = position.and_then(|position| siblings.get(position + 1)).copied(); ui.horizontal(|ui| { save = ui.add_enabled(can_edit && !state.draft.kind.is_empty() && edit.is_some(), egui::Button::new(tl!("Save tag properties"))).clicked(); if ui.add_enabled(dirty, egui::Button::new(tl!("Revert changes"))).clicked() { state.draft = Draft::from_tag(tag); } }); - (page, if save { edit } else { None }) + let (mut earlier, mut later) = (false, false); + ui.horizontal(|ui| { + earlier = ui.add_enabled(can_edit && !dirty && before.is_some(), egui::Button::new(tl!("Move tag earlier"))).clicked(); + later = ui.add_enabled(can_edit && !dirty && after.is_some(), egui::Button::new(tl!("Move tag later"))).clicked(); + }); + ( + page, + if save { + edit + } else if earlier { + before.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::Before }) + } else if later { + after.map(|anchor| Edit::MoveTag { object: tag.object, anchor: anchor.object, placement: TagPlacement::After }) + } else { + None + }, + ) } fn visible_tags<'a>(tags: &'a [TagInfo], query: &str, collapsed: &BTreeSet) -> Vec<&'a TagInfo> { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0d93fecee4..9f2bb90090 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -218,6 +218,8 @@ pub fn install_fonts_for(ctx: &egui::Context, cjk: CjkPreference) { pub const SYSTEM_FALLBACK: &str = "system-fallback"; /// The name of the installed Devanagari (Hindi) face [`installed_font_definitions`] may add. pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback"; +/// The name of the installed Telugu face [`installed_font_definitions`] may add. +pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback"; /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. @@ -230,8 +232,8 @@ pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback"; /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// -/// An installed Devanagari face, when there is one, ends every family just before that one, so -/// Hindi text draws in a build without a Devanagari face. +/// An installed Devanagari face and an installed Telugu face, when there are, end every family +/// just before that one, so Hindi and Telugu text draw in a build without such faces (#1531). /// /// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out. pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { @@ -241,10 +243,14 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { { use crate::system_fonts::{Need, fallback}; // Before the general installed face, which stays the last fallback outside Chinese. - if let Some(data) = crate::system_fonts::devanagari_fallback() { - fonts.font_data.insert(SYSTEM_DEVANAGARI_FALLBACK.to_owned(), data); + for (name, data) in [ + (SYSTEM_DEVANAGARI_FALLBACK, crate::system_fonts::devanagari_fallback()), + (SYSTEM_TELUGU_FALLBACK, crate::system_fonts::telugu_fallback()), + ] { + let Some(data) = data else { continue }; + fonts.font_data.insert(name.to_owned(), data); for stack in fonts.families.values_mut() { - stack.push(SYSTEM_DEVANAGARI_FALLBACK.to_owned()); + stack.push(name.to_owned()); } } let need = match cjk { diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs new file mode 100644 index 0000000000..eacf854c22 --- /dev/null +++ b/crates/ui-egui/src/window_controls.rs @@ -0,0 +1,288 @@ +//! Window buttons and resize edges for desktops that leave the window frame to the app. +//! +//! GNOME on Wayland has no server-side decorations (the compositor offers no +//! `zxdg_decoration_manager_v1`), so winit drew its own imitation frame. Instead the app turns the +//! frame off and the tab strip becomes the header bar, as in GNOME's own apps: it carries the +//! minimize, maximize and close buttons in the desktop's order (GNOME's `button-layout`), and +//! thin zones along the window's edges resize it. Desktops with server-side decorations (KDE, +//! Sway, Hyprland, X11) keep their own frame and never get here. + +use crate::theme::Tokens; +use egui::viewport::ResizeDirection; +use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, Vec2}; + +/// A window button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Button { + Minimize, + Maximize, + Close, +} + +/// Which buttons go on which end of the header bar, outermost last. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layout { + pub left: Vec