diff --git a/Cargo.lock b/Cargo.lock index fd186d995c..d2f4dadad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,17 +1178,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "egui", "serde", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-keymap", "craft-quit", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-winprint-gdi", "log", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "dispatch2", "egui", @@ -1259,7 +1259,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "chrono", "dunce", @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "craft-update", @@ -4232,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4266,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4288,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4306,11 +4306,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-content" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "hayro-syntax", @@ -4333,11 +4333,10 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.16.1" +version = "0.17.0" dependencies = [ "encoding_rs", "hayro-ccitt", - "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", @@ -4353,7 +4352,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.16.1" +version = "0.17.0" dependencies = [ "aes", "md-5", @@ -4365,7 +4364,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4374,6 +4373,8 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", + "proptest", "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", @@ -4381,7 +4382,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "craft-docpath", @@ -4393,10 +4394,12 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-audit", "pdfcraft-compare", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-create", "pdfcraft-edit", "pdfcraft-export", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-geom", @@ -4423,14 +4426,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.16.1" +version = "0.17.0" dependencies = [ "fax", "flate2", @@ -4443,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.16.1" +version = "0.17.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4453,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4466,11 +4469,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-js" -version = "0.16.1" +version = "0.17.0" dependencies = [ "boa_engine", "chrono", @@ -4478,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4491,14 +4494,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.16.1" +version = "0.17.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4509,7 +4512,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.16.1" +version = "0.17.0" dependencies = [ "image", "pdfcraft-annot", @@ -4525,11 +4528,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-organize" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4540,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.16.1" +version = "0.17.0" dependencies = [ "async-channel", "futures-lite", @@ -4550,7 +4553,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4561,7 +4564,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.16.1" +version = "0.17.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4573,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4590,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.16.1" +version = "0.17.0" dependencies = [ "bytemuck", "flate2", @@ -4609,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.16.1" +version = "0.17.0" dependencies = [ "aes", "aws-lc-rs", @@ -4645,11 +4648,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.16.1" +version = "0.17.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4673,6 +4676,7 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-fonts", @@ -4683,6 +4687,7 @@ dependencies = [ "plist", "png 0.18.1", "pollster 1.0.1", + "proptest", "rfd", "rustix 1.1.5", "serde", @@ -4701,7 +4706,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.16.1" +version = "0.17.0" dependencies = [ "eframe", "egui", @@ -4713,7 +4718,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "image", @@ -4727,7 +4732,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 85d02570ba..edd9e682b7 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.16.1" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,52 +13,52 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere. -craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.16.1" } -pdfcraft-filters = { path = "crates/filters", version = "0.16.1" } -pdfcraft-cos = { path = "crates/cos", version = "0.16.1" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" } -pdfcraft-organize = { path = "crates/organize", version = "0.16.1" } -pdfcraft-measure = { path = "crates/measure", version = "0.16.1" } -pdfcraft-annot = { path = "crates/annot", version = "0.16.1" } -pdfcraft-audit = { path = "crates/audit", version = "0.16.1" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" } -pdfcraft-model = { path = "crates/model", version = "0.16.1" } -pdfcraft-content = { path = "crates/content", version = "0.16.1" } -pdfcraft-redact = { path = "crates/redact", version = "0.16.1" } -pdfcraft-print = { path = "crates/print", version = "0.16.1" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" } -pdfcraft-sign = { path = "crates/sign", version = "0.16.1" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" } -pdfcraft-js = { path = "crates/js", version = "0.16.1" } -pdfcraft-compare = { path = "crates/compare", version = "0.16.1" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" } -pdfcraft-export = { path = "crates/export", version = "0.16.1" } -pdfcraft-edit = { path = "crates/edit", version = "0.16.1" } -pdfcraft-create = { path = "crates/create", version = "0.16.1" } -pdfcraft-forms = { path = "crates/forms", version = "0.16.1" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" } -pdfcraft-render = { path = "crates/render", version = "0.16.1" } -pdfcraft-engine = { path = "crates/engine", version = "0.16.1" } -pdfcraft-platform = { path = "crates/platform", version = "0.16.1" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" } -pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.17.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.17.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.17.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.17.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.17.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.17.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.17.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" } +pdfcraft-model = { path = "crates/model", version = "0.17.0" } +pdfcraft-content = { path = "crates/content", version = "0.17.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.17.0" } +pdfcraft-print = { path = "crates/print", version = "0.17.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.17.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" } +pdfcraft-js = { path = "crates/js", version = "0.17.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.17.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" } +pdfcraft-export = { path = "crates/export", version = "0.17.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.17.0" } +pdfcraft-create = { path = "crates/create", version = "0.17.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.17.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" } +pdfcraft-render = { path = "crates/render", version = "0.17.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.17.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.17.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/README.md b/README.md index ce9e71d4a9..3c1b67936a 100644 --- a/README.md +++ b/README.md @@ -547,9 +547,18 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 14419f9857..a39d6f8b6c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,11 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -213,6 +219,18 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. + +- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. + +- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. +- 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. + +- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. + +- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged. +- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths. + - **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. @@ -727,6 +745,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index d2a75c5941..5bc5c5c372 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -10,7 +10,7 @@ //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on //! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off -//! --page-gaps on|off --default-page-gaps on|off +//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50 //! --combine-view grid|list --combine-zoom 60..200` //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files diff --git a/contributors/people.toml b/contributors/people.toml index a5a47f5c52..fecfb68c90 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Combiler01] +sync_github_name = true diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..909250ef26 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -392,9 +392,13 @@ struct AppearanceDrawing { } impl AppearanceDrawing { + /// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode. fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); - if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + if let Some(gs) = ext_gstate( + context.opacity, + context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), + ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); drawing.content.push_str("/GS0 gs\n"); } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..1904745e95 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -33,6 +33,16 @@ pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { [r[0] - by, r[1] - by, r[2] + by, r[3] + by] } +/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or +/// more points (matching [`appearance::smooth_segments`]). +pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> { + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + bounds(strokes.iter().flatten().copied().chain(controls)) +} + pub(crate) fn ending_draws(ending: LineEnding) -> bool { ending != LineEnding::None } @@ -87,17 +97,11 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) + grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } Shape::Polygon { vertices, cloud } => { let b = bounds(vertices.iter().copied()) @@ -164,6 +168,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::Oval { .. } => "Oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", Shape::Line { .. } => "Line", + Shape::FreehandHighlight { .. } => "Freehand Highlight", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", Shape::Typewriter { .. } => "Typewriter", @@ -292,10 +297,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } } } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); d.set(b"InkList".to_vec(), Object::Array(list)); + if matches!(&new.shape, Shape::FreehandHighlight { .. }) { + d.set(b"IT".to_vec(), Object::name("FreehandHighlight")); + } border(&mut d); } Shape::Polygon { vertices, cloud } => { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..a615b8e013 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,5 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -529,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } fn border_width_of(d: &Dict) -> f64 { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) + appearance::border_width(d) +} + +/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`. +/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating +/// padding across repeated width increases and decreases. +fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { + let list = d.get(b"InkList")?.as_array()?; + let mut strokes: Vec> = Vec::with_capacity(list.len()); + for s in list { + let pts: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()); + } + let inner = ink_bounds(&strokes)?; + let vals: Vec = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + let Some(old_rect) = four(&vals).map(normalize) else { + return Some(grow(inner, w / 2.0 + 1.0)); + }; + let old_half = old_w / 2.0; + let new_half = w / 2.0; + let base = [ + (inner[0] - old_rect[0] - old_half).max(1.0), + (inner[1] - old_rect[1] - old_half).max(1.0), + (old_rect[2] - inner[2] - old_half).max(1.0), + (old_rect[3] - inner[3] - old_half).max(1.0), + ]; + Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. @@ -576,10 +602,17 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } } if let Some(w) = width { + let old_w = border_width_of(d); + let w = w.max(0.0); let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); + if d.name(b"Subtype") == Some(b"Ink") + && let Some(rect) = ink_rect_for_width(d, old_w, w) + { + d.set(b"Rect".to_vec(), num_array(&rect)); + } } if let Some(ends) = endings { if ends.len() == 1 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..cf800fa98a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,59 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +#[test] +fn freehand_highlight_is_compatible_ink_with_marker_blending() { + let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; + let mut annotation = new(0, shape.clone()); + annotation.style = Style::default_for(&shape); + let mut doc = fixture(); + let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap(); + let doc = reopen(&doc); + let ink = &list(&doc, 0)[index]; + + assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..])); + assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..])); + assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35)); + let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap(); + let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap(); + assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); + + let mut doc = doc; + let rect_of = |doc: &Document, idx: usize| -> Vec { + list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect() + }; + let rect_before = rect_of(&doc, index); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); + let rect_after = rect_of(&doc, index); + assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); + assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); + + // Repeated increase/decrease cycles must keep bounds stable and never accumulate padding. + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap(); + let rect_restored = rect_of(&doc, index); + for (a, b) in rect_restored.iter().zip(rect_before.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}"); + } + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap(); + let rect_second_wide = rect_of(&doc, index); + for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}"); + } + + // Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its + // previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt). + let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)])); + d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0])); + }) + .unwrap(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap(); + let rect_from_border = rect_of(&doc, index); + for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) { + assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); + } +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..522995b9f9 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -355,6 +355,10 @@ pub enum Shape { start: LineEnding, end: LineEnding, }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, /// Freehand strokes (Draw tool). Ink { strokes: Vec>, @@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { } impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. pub fn subtype(&self) -> &'static str { match self { Shape::Note { .. } => "Text", @@ -481,7 +486,7 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink", Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", Shape::Redact { .. } => "Redact", @@ -610,6 +615,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), diff --git a/crates/automation/README.md b/crates/automation/README.md index 176946c175..92fd2775c9 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec and flips. Angles that are not multiples of 90 are refused without changing the document or its undo/redo history. +`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry +follows the box-level `align`; legacy `null` entries remain accepted with the same meaning. + `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions @@ -199,6 +202,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). +### Superscript and subscript normalization + +`text_script` changes characters to their Unicode superscript or subscript forms; it does not +change the font size or baseline. For characters that have such forms, uniformly plain text +toggles to the requested script, and uniformly scripted text toggles back to plain text. +Characters without a matching form stay unchanged and do not determine the selection's state. + +A selection mixing plain and target-script characters normalizes to the requested script. +For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript +changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original +mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text, +including its original mixture. Save/reopen preserves the resulting normalized characters. + ## Opening local GoToR links `link_open {doc, page, index, password?}` follows a local PDF link using `link_list` diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..5dbd5b8542 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -183,6 +183,7 @@ impl Automation { "page": c.page + 1, "index": c.index + 1, "type": c.subtype, + "intent": c.intent, "author": c.author, "contents": c.contents, "modified": c.modified, @@ -328,7 +329,7 @@ impl Automation { let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } - "ink" => { + "ink" | "freehand_highlight" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?; let strokes = strokes @@ -346,7 +347,7 @@ impl Automation { .collect::>>() }) .collect::>>()?; - Shape::Ink { strokes } + if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } } } "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 7518072d1c..6b25c31f61 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result { /// Most entries `aligns` takes: one per paragraph, and no text has this many. const MAX_ALIGNS: usize = 4096; -/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`. pub(crate) fn opt_aligns(a: &Args) -> Result>>> { let Some(v) = a.get("aligns") else { return Ok(None) }; - let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?; if items.len() > MAX_ALIGNS { return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); } @@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result>>> { .iter() .map(|v| match v { Value::Null => Ok(None), + Value::String(s) if s == "inherit" => Ok(None), Value::String(s) => parse_align(s).map(Some), - _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + _ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")), }) .collect::>>() .map(Some) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 36e0d00454..8bc23ff280 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -12,6 +12,35 @@ pub enum Content { Png { data: Vec, width: u32, height: u32 }, } +/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point +/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is +/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by +/// an affine map, so three corners of the crop box pin the result down. +fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> { + let [a, b, c, d, e, f] = m; + let det = a * d - b * c; + if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 { + return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into())); + } + let moved = |x: f32, y: f32| { + let [vx, vy] = info.user_to_view(x, y).map(f64::from); + info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from) + }; + let [x0, y0, x1, y1] = info.crop; + let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0)); + if w.abs() < 1e-3 || h.abs() < 1e-3 { + return Err(ToolError::InvalidArgs("the page has no area to transform in".into())); + } + let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1)); + let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h); + let (x0, y0) = (f64::from(x0), f64::from(y0)); + let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0]; + if !user.iter().all(|v| v.is_finite()) { + return Err(ToolError::InvalidArgs("the transform moves the image too far".into())); + } + Ok(user) +} + impl Automation { /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). pub fn call(&mut self, name: &str, args: &Value) -> Result> { @@ -357,7 +386,7 @@ impl Automation { let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) }) .collect(); json!({ "page": page + 1, "count": list.len(), "images": list }) @@ -395,6 +424,16 @@ impl Automation { let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } + "transform" => { + let m: Vec = a + .get("matrix") + .and_then(Value::as_array) + .and_then(|x| x.iter().map(Value::as_f64).collect()) + .ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?; + let m = <[f64; 6]>::try_from(m) + .map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?; + ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?) + } "rotate_by" => ImageEdit::RotateBy( a.get("degrees") .and_then(Value::as_f64) @@ -410,9 +449,18 @@ impl Automation { let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, + action @ ("flip_horizontal" | "flip_vertical") => { + let horizontal = action == "flip_horizontal"; + let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?; + // The tool names displayed axes; the engine names PDF user-space axes. + ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) } + } "delete" => ImageEdit::Delete, + "bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + "send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + "bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + "send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + "remove_crop" => ImageEdit::RemoveCrop, "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = read_input(&path)?; @@ -624,6 +672,7 @@ impl Automation { } out } + "text_script" => self.text_script(&a)?, "doc_audit_space" => { let rows: Vec = self .doc(&a)? @@ -815,4 +864,52 @@ impl Automation { }; Ok(vec![Content::Json(out)]) } + + /// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range + /// of its editor text, written as `text_edit` writes the paragraph. + fn text_script(&mut self, a: &Args) -> Result { + use pdfcraft_engine::text_script::{Script, toggle}; + let name = a.str("script")?; + let script = match name { + "superscript" => Script::Super, + "subscript" => Script::Sub, + other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))), + }; + let page = self.page(a)?; + let k = a.int("paragraph")?; + let text = { + let doc = self.doc(a)?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))? + }; + let range = match a.opt_ints("range")? { + None => None, + Some(r) => { + let bounds = match r.as_slice() { + [start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()), + _ => None, + }; + let range = bounds + .map(|(start, end)| start..end) + .filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end)); + let Some(range) = range else { + return Err(ToolError::InvalidArgs(format!( + "range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries", + text.len() + ))); + }; + Some(range) + } + }; + let (toggled, _) = toggle(&text, range.as_ref(), script) + .ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?; + let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled }); + let mut out = match self.call("text_edit", &args)?.into_iter().next() { + Some(Content::Json(out)) => out, + _ => json!({}), + }; + out["script"] = json!(name); + Ok(out) + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..866138899d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,7 +175,7 @@ pub fn tools() -> Vec { }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), - t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.") + t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, @@ -637,7 +637,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], @@ -660,7 +660,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, @@ -754,7 +754,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), @@ -1281,7 +1281,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1378,17 +1378,18 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "degrees": { "type": "number" }, + "matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 }, "path": { "type": "string" }, }), &["doc", "page", "image", "action"], @@ -1428,7 +1429,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, @@ -1438,11 +1439,19 @@ pub fn tools() -> Vec { "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), + t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.") + .cmd("edit.edit_text") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "script": { "type": "string", "enum": ["superscript", "subscript"] }, + "range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }), + &["doc", "page", "paragraph", "script"], + )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..0d73dbb900 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -664,6 +664,44 @@ fn tool_table_is_well_formed() { } } +fn assert_homogeneous_enums(schema: &Value, path: &str) { + fn json_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + if let Some(values) = schema.get("enum").and_then(Value::as_array) { + let first = values.first().map(json_kind); + assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}"); + } + match schema { + Value::Object(object) => { + for (key, child) in object { + assert_homogeneous_enums(child, &format!("{path}.{key}")); + } + } + Value::Array(array) => { + for (index, child) in array.iter().enumerate() { + assert_homogeneous_enums(child, &format!("{path}[{index}]")); + } + } + _ => {} + } +} + +#[test] +fn tool_schema_enums_have_one_json_type() { + for tool in tools() { + assert_homogeneous_enums(&tool.input_schema, tool.name); + } +} + #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); @@ -2111,6 +2149,27 @@ fn comments_through_tools() { save_and_reopen_comments(&dir, &mut a, doc); } +#[test] +fn freehand_highlight_through_tools() { + let dir = workdir("freehand-highlight"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100, + "strokes": [[[20, 100], [120, 100]]] }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1); + assert_eq!(list["comments"][0]["type"], "Ink"); + assert_eq!(list["comments"][0]["intent"], "FreehandHighlight"); + assert_eq!(list["comments"][0]["color"], "#FFF000"); + let undone = ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(undone["undone"], "Add freehand highlight"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); +} + fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); @@ -3286,6 +3345,39 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space +/// (which has no superscript form), each one undo step; refusals change nothing. +#[test] +fn superscript_and_subscript_through_tools() { + let dir = workdir("text-script"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let paragraph = + |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string(); + let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range }); + // "Page 2": the 2 is byte 5. + let r = ok(&mut a, "text_script", script("superscript", json!([5, 6]))); + assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}"); + assert_eq!(page_text(&mut a, doc)[1], "Page ²"); + // " ²": the space has no superscript form, and the range still toggles back. + ok(&mut a, "text_script", script("superscript", json!([4, 7]))); + assert_eq!(paragraph(&mut a), "Page 2"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + assert_eq!(paragraph(&mut a), "Page ²"); + // Refused, changing nothing: a range that splits ², one past the end, an empty one, a range + // with nothing that has a subscript form, an unknown script. + for (name, range) in [ + ("superscript", json!([5, 6])), + ("superscript", json!([0, 99])), + ("superscript", json!([3, 3])), + ("subscript", json!([4, 5])), + ("italic", json!([0, 1])), + ] { + assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}"); + } + assert_eq!(paragraph(&mut a), "Page ²"); +} + #[test] fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { if pdfcraft_fonts::document_japanese_font().is_none() { @@ -3453,6 +3545,13 @@ fn editing_page_images_through_tools() { assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + // A single image is already in front and at the back; no-op arrange is refused without dirtying undo. + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })).is_err()); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" })); + assert_eq!(r["undo"], "Remove crop"); // Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn. let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 })); @@ -3472,6 +3571,80 @@ fn editing_page_images_through_tools() { assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn arranging_page_images_through_tools() { + let dir = workdir("arrange-page-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": made, "page": 1, "path": "src/a_page_2.png", "rect": [50, 50, 100, 100] })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 2); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })); + assert_eq!(r["undo"], "Bring to front"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 2, "action": "send_to_back" })); + assert_eq!(r["undo"], "Send to back"); + ok(&mut a, "doc_save", json!({ "doc": made, "path": "arranged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "arranged.pdf" }))["doc"].as_u64().unwrap(); + let after_reopen = ok(&mut a, "page_images", json!({ "doc": reopened, "page": 1 })); + assert_eq!(after_reopen["count"], 2); + assert_eq!(after_reopen["images"][0]["rect"], json!([0.0, 0.0, 50.0, 75.0])); + assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0])); +} + +/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly, +/// in the same top-left-origin points as the other image tools, also on a turned page. +#[test] +fn transforming_page_images_through_tools() { + let dir = workdir("transform-page-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap(); + let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + let transform = + |a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m })); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] })); + + // Right 10, down 20. + assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image"); + assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0])); + // Twice the size about the top-left corner of the page. + transform(&mut a, json!([2, 0, 0, 2, 0, 0])); + assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0])); + // A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step. + transform(&mut a, json!([0, 1, -1, 0, 200, 0])); + assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0])); + ok(&mut a, "edit_undo", json!({ "doc": made })); + assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0])); + + // On a turned page "right" is still right on screen. + ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 })); + let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap()); + let [x0, y0, x1, y1] = numbers(rect(&mut a)); + transform(&mut a, json!([1, 0, 0, 1, 10, 0])); + let after = numbers(rect(&mut a)); + for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) { + assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10"); + } + + // Flat, non-finite, short and missing matrices are refused before anything changes. + let before = rect(&mut a); + for bad in [ + json!([1, 2, 2, 4, 0, 0]), + json!([0, 0, 0, 0, 0, 0]), + json!([1e300, 0, 0, 1e300, 0, 0]), + json!([1, 0, 0, 1]), + json!([1, 0, "invalid", 0, 1, 10, 0]), + json!([1, 0, null, 0, 1, 10, 0]), + ] { + let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad })); + assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}"); + } + assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(rect(&mut a), before); +} + #[test] fn enhance_camera_image_through_tools() { let dir = workdir("enhance-camera"); @@ -9764,7 +9937,11 @@ fn added_text_lines_take_their_own_alignments() { let dir = workdir("paragraph-aligns"); let mut a = auto(&dir); let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); - ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]})); + ok( + &mut a, + "page_add_text", + json!({"doc":id,"page":1,"text":"Left line\nRight line\nInherited line","at":[40,50],"width":500,"size":12,"aligns":[null,"right","inherit"]}), + ); let rect = |a: &mut Automation, text: &str| -> Vec { let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); @@ -9772,10 +9949,13 @@ fn added_text_lines_take_their_own_alignments() { }; assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0); // The whole box centred: the right-aligned line goes with it. ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); let right = rect(&mut a, "Right line"); assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + let inherited = rect(&mut a, "Inherited line"); + assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}"); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); } @@ -10190,3 +10370,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } write_full(&doc, &SaveOptions::default()).unwrap() } + +#[test] +fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() { + let dir = workdir("editor-crop-group-arrange"); + let mut a = auto(&dir); + let source = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({"doc":source,"folder":"src","dpi":18,"pages":[1]})); + let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["src/a_page_1.png"]}))["doc"].as_u64().unwrap(); + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[5,5,25,35]})); + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"crop","rect":[10,10,20,30]})); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Movable label","rect":[2,40,45,55],"size":6})); + let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect(); + assert_eq!(objects.len(), 2); + let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"objects":objects,"offset":[3,4]})); + assert_eq!(moved["moved"], 2); + let picture = ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0].clone(); + assert_eq!(picture["rect"], json!([8.0, 9.0, 28.0, 39.0])); + assert_eq!(picture["visible_rect"], json!([13.0, 14.0, 23.0, 34.0])); + assert!(page_text(&mut a, doc)[0].contains("Movable label")); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0]["visible_rect"], json!([10.0, 10.0, 20.0, 30.0])); + ok(&mut a, "edit_redo", json!({"doc":doc})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"grouped.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"grouped.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc":reopened,"page":1}))["images"][0]["visible_rect"], picture["visible_rect"]); + ok(&mut a, "image_edit", json!({"doc":reopened,"page":1,"image":1,"action":"bring_to_front"})); + assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 1); + ok(&mut a, "content_delete", json!({"doc":reopened,"page":1,"index":1})); + ok(&mut a, "doc_save", json!({"doc":reopened,"path":"independent.pdf"})); + let saved = ok(&mut a, "doc_open", json!({"path":"independent.pdf"}))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "page_images", json!({"doc":saved,"page":1})); + assert_eq!(after["count"], 1); + assert_eq!(after["images"][0]["rect"], picture["rect"]); + assert_eq!(after["images"][0]["visible_rect"], picture["visible_rect"]); + let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0))); +} + +#[test] +fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() { + fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] { + let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap(); + let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix; + [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] + .map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from)) + } + fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec) { + let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut bytes = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut bytes).unwrap(); + bytes.truncate(frame.buffer_size()); + if frame.color_type == png::ColorType::Rgb { + bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(); + } else { + assert_eq!(frame.color_type, png::ColorType::Rgba); + } + (frame.width as usize, frame.height as usize, bytes) + } + let dir = workdir("displayed-image-flip"); + let mut png_bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4); + encoder.set_color(png::ColorType::Rgb); + let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]]; + let pixels: Vec = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect(); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + } + std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap(); + let mut a = auto(&dir); + let mut failures = Vec::new(); + for rotation in [0, 90, 180, 270] { + let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap(); + if rotation != 0 { + ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation})); + } + let before = corners(&a, doc); + let (width, height, pixels) = raster(&mut a, doc); + let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min); + let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max); + let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min); + let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max); + for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] { + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action})); + let after = corners(&a, doc); + for (old, new) in before.into_iter().zip(after) { + let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] }; + if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) { + failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}")); + break; + } + } + let (new_width, new_height, actual_pixels) = raster(&mut a, doc); + assert_eq!((new_width, new_height), (width, height)); + let mut expected_pixels = Vec::new(); + for y in 0..height { + for x in 0..width { + let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 }; + expected_pixels.extend_from_slice(&pixels[source..source + 4]); + } + } + if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) { + failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis")); + } + assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(corners(&a, doc), before, "one undo restores the original placement"); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() { + let dir = workdir("mixed-script-policy"); + let mut a = auto(&dir); + for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] { + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160})); + let paragraph = + |a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned(); + assert_eq!(paragraph(&mut a), mixed); + let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script}); + let once = ok(&mut a, "text_script", args.clone()); + assert_eq!(once["paragraph"]["text"], uniform); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "text_script", args); + assert_eq!(paragraph(&mut a), plain); + assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture"); + assert_eq!( + a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(), + before.as_ref(), + "Undo also restores the exact prior document bytes" + ); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), plain); + let path = format!("mixed-{script}.pdf"); + ok(&mut a, "doc_save", json!({"doc":doc,"path":path})); + let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened)[0], plain); + } +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c52916d332..4957f86861 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -730,11 +730,14 @@ impl Matrix { pub fn invert(&self) -> Option { let [a, b, c, d, e, f] = self.0; let det = a * d - b * c; - if det.abs() < 1e-12 || !det.is_finite() { + if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) { return None; } let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) + let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]; + // A determinant that underflows toward zero can still pass the relative check above, + // and dividing by it overflows: such a matrix has no usable inverse. + inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse)) } /// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index c5c7a3aaac..9ac9297cd2 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() { assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); } } + +#[test] +fn invert_accepts_small_scale_and_rejects_singular() { + let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]); + let inv = m.invert().expect("a uniform 1e-7 scale is invertible"); + let id = m.then(&inv).0; + for (got, want) in id.iter().zip(Matrix::IDENTITY.0) { + assert!((got - want).abs() < 1e-9, "{id:?}"); + } + assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none()); + assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none()); + // A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703). + assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none()); +} diff --git a/crates/cos/README.md b/crates/cos/README.md index 2cf3342954..1e2551036f 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -90,6 +90,8 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding - (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer - (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding + (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, + their filters undone once a document for the images that share them, up to 64 MiB kept, while + their segments are decoded for every image); + DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0d9b84b749..082d1910d1 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; +use crate::object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream}; use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; mod objects; @@ -49,6 +49,9 @@ pub struct Document { repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, + /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan + /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). + jbig2_globals: Arc>>>>, stream_limit: usize, overlay: BTreeMap, next_num: u32, diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 4f05d03ae0..bab99ac45e 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,13 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; +use super::{ + Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, +}; + +/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see +/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`]. +const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS; impl Document { pub fn bytes(&self) -> &Arc> { @@ -233,18 +239,7 @@ impl Document { if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { return None; } - // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the - // last filter but a /Crypt, which `filters` leaves out wherever it stands. - let index = match s.dict.get(b"Filter") { - Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), - _ => 0, - }; - let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { - Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), - Some(p) if index == 0 => p.as_dict().cloned(), - _ => None, - } - .unwrap_or_default(); + let parms = self.last_filter_parms(s); let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { Some(Object::Bool(b)) => *b, @@ -262,23 +257,62 @@ impl Document { }) } + /// The `/DecodeParms` dictionary of stream `s`'s last filter, resolved: that of a lone filter, + /// or its entry in an array. Empty when there is none. + fn last_filter_parms(&self, s: &Stream) -> Dict { + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default() + } + /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a - /// CCITT fax image (its last filter), through the filters before it and then - /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` - /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter - /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to - /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged - /// data gives fewer, so a caller checks it has the rows it needs. + /// CCITT fax or JBIG2 image (its last filter), through the filters before it and then + /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], or + /// [`pdfcraft_filters::decode_jbig2`] with its `/JBIG2Globals`, as `CCITTFaxDecode` and + /// `JBIG2Decode` give them (ISO 32000-2 §7.4.6, §7.4.7). Parameters given by reference count, + /// for every filter ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its + /// `/Width`, and a JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the + /// whole page into the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says, + /// within [`MAX_DECODED`] bytes; damaged fax data gives fewer, so a caller checks it has the + /// rows it needs. Damaged JBIG2 data, and globals that can't be read (within + /// [`MAX_JBIG2_GLOBALS`]), are an error. pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { self.decoded_image_within(s, MAX_DECODED) } /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than - /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than - /// `max` bytes. + /// [`MAX_DECODED`]: no stage, the filters before a fax or JBIG2 decoder included, may produce + /// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between + /// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is. pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = s.readable_filters().map_err(filter)?; + // The filters before the image codec, such as ASCII85Decode, are undone first. + let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)]| match before { + [] => Ok(Cow::Borrowed(&s.raw[..])), + _ => pdfcraft_filters::decode_tolerant(before, &s.raw, max).map(|(data, _)| Cow::Owned(data)).map_err(filter), + }; + // `/Width` and `/Height` are read as given directly, as every caller reads them first: one + // given by reference has the image refused there, before it gets here. + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()); + if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() { + let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else { + return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into())); + }; + let globals = self.jbig2_globals(s)?; + return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter); + } let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and // rows past /Height aren't drawn. @@ -286,18 +320,65 @@ impl Document { if s.dict.int(b"Width") != Some(p.columns) { return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); } - let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); - let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); - // The filters before it, such as ASCII85Decode, are undone first. - let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; - let data = match chain.split_last() { - Some((_, before)) if !before.is_empty() => { - Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) - } - _ => Cow::Borrowed(&s.raw[..]), + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0)); + let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?; + pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter) + } + + /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's + /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without + /// globals. Redaction finds with it the other images that share a redacted image's globals. + pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return None; + } + self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) + } + + /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) + /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is + /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read + /// without them, as the renderer draws it, so an image that needed them mostly fails to decode + /// and can decode to other pixels. A stream that is there and can't be read is an error, rather + /// than the image decoded without it. + /// + /// The images of a scan share one globals stream, so a stream of the file, not edited since + /// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they + /// are another object, which can change while this one doesn't), has its filters undone once a + /// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That + /// saves the filters only: the segments it holds are decoded again for every image. + fn jbig2_globals(&self, s: &Stream) -> Result>>, CosError> { + let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) }; + let held = match entry { + Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num), + _ => None, }; - pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) { + return Ok(Some(hit)); + } + let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) }; + let readable = self.with_direct_parms(g); + let held = held.filter(|_| matches!(readable, Cow::Borrowed(_))); + let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| { + let detail = match e { + CosError::Filter(detail) => detail, + e => e.to_string(), + }; + CosError::Filter(format!("the JBIG2 globals can't be read: {detail}")) + })?; + // What is kept counts by its length, so that's all it may hold on to. + data.shrink_to_fit(); + let data = Arc::new(data); + // A poisoned lock only means nothing is kept. + if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) { + let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add); + if total <= JBIG2_GLOBALS_HELD { + kept.insert(num, data.clone()); + } + } + Ok(Some(data)) } pub fn try_get(&self, num: u32) -> Result, CosError> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 57d697ed66..395424b771 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -19,6 +19,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: crate::object::MAX_DECODED, overlay: BTreeMap::new(), next_num: 1, @@ -102,6 +103,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: stream_limit.min(crate::object::MAX_DECODED), overlay: BTreeMap::new(), next_num: 1, diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f5e36f9c83..cca4b87326 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -173,6 +173,217 @@ fn fax_images_decode_with_their_parameters() { assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); } +/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept +/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the +/// file's), and objects added in memory aren't kept. +#[test] +fn shared_jbig2_globals_are_read_once() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let image = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))) + }; + let (a, b) = (image(&mut doc), image(&mut doc)); + let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept"); + + // The same objects read from a file, the catalog holding on to the images (a full save may + // number them anew). + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap(); + let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(file)).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap()); + let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone(); + let globals = parms.reference(b"JBIG2Globals").unwrap(); + let clone = doc.clone(); + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept"); + let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image"); + let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode"); + doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))); + assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh"); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's"); +} + +/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image +/// with neither is an error, not an image of no rows. +#[test] +fn a_jbig2_image_needs_its_size_given_directly() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap(); + let mut doc = Document::new_empty(); + let size = doc.add(Object::Int(2)); + let image = |width: Object, height: Object| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), width); + d.set(b"Height".to_vec(), height); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + assert!(image(Object::Int(8), Object::Int(2)).is_ok()); + assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference"); + assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference"); +} + +/// A document that kept globals of 64 MiB keeps no more, and the images past that still read: +/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time. +#[test] +fn kept_jbig2_globals_are_bounded_in_total() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let length = 22 * 1024 * 1024; + let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0]; + skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes()); + skipped.extend_from_slice(&[0, 0, 0, 1]); + skipped.resize(skipped.len() + length, 0); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + for _ in 0..3 { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))))); + } + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap(); + let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + for image in &images { + let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") }; + assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads"); + } + let kept = doc.jbig2_globals.lock().unwrap(); + assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't"); + assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts"); +} + +/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so +/// editing it changes what they decode to while the stream itself, the key kept by, stays the same. +#[test] +fn jbig2_globals_with_parameters_by_reference_are_read_afresh() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 }; + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(15)); + let parms = doc.add(Object::Dict(parms)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &skipped).unwrap(); + let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut image_parms = Dict::new(); + image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms)); + let image = doc.add(Object::Stream(Stream::from_raw(d, coded))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap(); + let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap(); + let stream = |doc: &Document| match &*doc.get(image) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept"); + let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap(); + let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") }; + let parms = g.dict.reference(b"DecodeParms").unwrap(); + doc.set(parms, Object::Dict(Dict::new())); + assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise"); +} + +/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of +/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference +/// too): globals that can't be read are an error, and ones at another filter's place aren't its +/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its +/// /Width × /Height, and fit the bound. +#[test] +fn jbig2_images_decode_with_their_globals() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let mut doc = Document::new_empty(); + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data + // that isn't JBIG2. + let globals = |doc: &mut Document, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data)))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + Object::Dict(parms) + }; + let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2")); + let indirect = Object::Ref(doc.add(bad.clone())); + let image = |filters: &[&str], parms: Option, data: &[u8], (width, height): (i64, i64), max: usize| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max) + }; + let jbig2 = ["JBIG2Decode"]; + let full = (10, 3); + assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits); + assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips"); + for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] { + let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err(); + assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}"); + } + let behind = ["ASCIIHexDecode", "JBIG2Decode"]; + assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode"); + assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals"); + assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's"); + assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's"); + let after_crypt = ["Crypt", "JBIG2Decode"]; + assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt"); + assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's"); + + for size in [(11, 3), (9, 3), (10, 2), (10, 4)] { + assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}"); + } + assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound"); + assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound"); +} + #[test] fn a_missing_file_id_is_derived_from_the_encryption_seed() { let mut doc = Document::new_empty(); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index f3be66b9a1..a35902a031 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,7 +25,7 @@ mod writer; pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; -pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; +pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 17ee7844cf..400fc29962 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -213,6 +213,12 @@ pub struct Stream { /// Upper bound for a single decoded stream (decompression-bomb defence). pub const MAX_DECODED: usize = 1 << 30; +/// The most an image's `/JBIG2Globals` stream may decode to: the symbol and pattern dictionaries an +/// encoder shares between pages, themselves coded, so far less than any stream may decode to +/// ([`MAX_DECODED`]). They are decoded before the image's JBIG2 page is read, so the page's size +/// can't bound them: they have a limit of their own. +pub const MAX_JBIG2_GLOBALS: usize = 32 << 20; + impl Stream { /// A new stream from already-encoded bytes (`/Length` is set at write time). pub fn from_raw(dict: Dict, raw: Vec) -> Self { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 627f716495..cd0137f452 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, if let Some(p) = prev { trailer.set(b"Prev".to_vec(), Object::Int(p as i64)); } - let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32); + let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32)); if as_stream { let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect(); write_xref_stream(&mut out, base, &mut trailer, rows, size); diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 1b98664255..eb03523189 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +#[test] +fn incremental_save_ignores_a_negative_trailer_size() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1)); + let next = doc.add(Object::Int(5)).num + 1; + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next))); +} + /// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is /// not written: the file never held it, and writing it would keep what was removed. Additions the /// update still refers to, from the trailer or a changed object and through other additions, are. diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index b41caa2b88..51eca668d0 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -15,8 +15,6 @@ pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } # For `image::bmp_with_pixel_offset`, shared with XFA pictures. pdfcraft-xfa = { workspace = true } -# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. -hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } diff --git a/crates/create/README.md b/crates/create/README.md index d359eed9ae..555d8b3aba 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -27,11 +27,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. -JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, -as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more -pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the -page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image -exported opaque, as JPX and CCITT masks are. +JBIG2 images and masks are decoded through `pdfcraft_cos::Document::decoded_image` (`pdfcraft_filters::decode_jbig2`), behind +other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image +whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), +or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a +limit of their own), is skipped; such a mask is left out and the image exported opaque, as JPX and CCITT +masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. @@ -43,4 +44,4 @@ CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::D `ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, -at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. +at `/K 0`, may come out wrong. JPEG 2000 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b12bd50ec2..34d479506b 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -100,114 +100,14 @@ fn filters(s: &Stream) -> Vec> { } } -/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given -/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor -/// given so would otherwise be lost), or, for a JBIG2 image -/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples -/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +/// The samples of image `s`, decoded ([`Document::decoded_image`]): through its filters, with a +/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by +/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image +/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. +/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, +/// before the page is decoded. fn decoded(doc: &Document, s: &Stream) -> Result, String> { - match filters(s).as_slice() { - [f] if f == b"JBIG2Decode" => jbig2(doc, s), - _ => doc.decoded_image(s).map_err(|e| e.to_string()), - } -} - -/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). -fn decode_parms(doc: &Document, s: &Stream) -> Dict { - let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; - let p = match &*p { - Object::Array(a) => match a.first() { - Some(first) => doc.resolve(first), - None => return Dict::default(), - }, - _ => p, - }; - p.as_dict().cloned().unwrap_or_default() -} - -/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder -/// shares between pages, themselves coded, so far less than any stream may decode to -/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. -const MAX_GLOBALS: usize = 32 << 20; - -/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller -/// checked against the export's size limit, and within that limit itself: the decoder decodes the -/// whole page, rows past the image's height too, so a small `/Height` over a far taller page -/// would cost more than the limit allows. Rows past the image's height are dropped, and too few -/// rows are refused by the caller. Globals that can't be read refuse the image, rather than -/// decoding it without them. They are decoded before the page's size is known, so the page's -/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. -fn jbig2(doc: &Document, s: &Stream) -> Result, String> { - let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { - Some(Object::Stream(g)) => { - Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) - } - _ => None, - }; - let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; - if s.dict.int(b"Width") != Some(i64::from(image.width())) { - return Err("the JBIG2 image's width isn't the image's width".into()); - } - if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { - return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); - } - let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); - let mut rows = Rows::new(image.width() as usize, height); - image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; - Ok(rows.out) -} - -/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` -/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so -/// the output is bounded by the size the caller gave. -struct Rows { - out: Vec, - row: Vec, - x: usize, - columns: usize, - rows: usize, - done: usize, -} - -impl Rows { - fn new(columns: usize, rows: usize) -> Self { - Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } - } - - /// `count` pixels, as 1 samples when `one`. - fn push(&mut self, one: bool, count: usize) { - let end = self.x.saturating_add(count); - if one { - for x in self.x..end.min(self.columns) { - if let Some(b) = self.row.get_mut(x / 8) { - *b |= 0x80 >> (x % 8); - } - } - } - self.x = end; - } - - fn end_row(&mut self) { - if self.done < self.rows { - self.out.extend_from_slice(&self.row); - self.done += 1; - } - self.row.fill(0); - self.x = 0; - } -} - -/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. -impl hayro_jbig2::Decoder for Rows { - fn push_pixel(&mut self, black: bool) { - self.push(!black, 1); - } - fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { - self.push(!black, (chunks as usize).saturating_mul(8)); - } - fn next_line(&mut self) { - self.end_row(); - } + doc.decoded_image(s).map_err(|e| e.to_string()) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft @@ -218,7 +118,6 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } @@ -236,11 +135,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> 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) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite()); let inverted = (jpeg || mask) && 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))) + // Both ends have to be finite numbers, as for lossless images, or the default mapping stands. + .and_then(|d| { + let a = d.as_array()?; + Some(endpoint(a.first())? > endpoint(a.get(1))?) + }) .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 @@ -612,7 +516,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> return Some(px); } let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?; - Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw()) + Some(scaled(&img, w as u32, h as u32).into_raw()) +} + +/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at +/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any +/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process. +/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a +/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was. +pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage { + use image::imageops::{FilterType, resize}; + let (mw, mh) = (u64::from(img.width()), u64::from(img.height())); + let largest = (mw * mh).max(u64::from(w) * u64::from(h)); + if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle) + } else { + resize(img, w, h, FilterType::Triangle) + } } fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result, String> { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 03c93d4a51..6df3ab73b2 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2054,10 +2054,10 @@ fn symbol_coded_jbig2_images_export_as_their_picture() { /// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in -/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the -/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, -/// or larger than an exported image may be (however small the image's `/Height`), or data that -/// isn't JBIG2, skips the image. +/// an array, so globals that can't be read skip the image rather than being ignored. A JBIG2 page +/// of another size than the image (a taller one too: PdfCraft's renderer draws the whole page into +/// the image), refused before it is decoded however large, or data that isn't JBIG2, skips the +/// image. #[test] fn jbig2_images_export_as_their_picture() { let (w, h) = (203i64, 41i64); @@ -2118,33 +2118,24 @@ fn jbig2_images_export_as_their_picture() { for in_array in [false, true] { assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); } - assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); - for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + for (width, height, data) in + [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h - 5, &jbig2[..]), (w, h, &b"not JBIG2"[..])] + { assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); } // The same stream with its page information (the first segment's data, after its 11-byte - // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the - // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page - // (it takes pages of up to about 2^28 pixels), so it is refused first. + // header: width, then height) claiming a 20,000 × 10,000 page, behind an image of 20,000 × 4: + // the decoder would decode the whole page, so it is refused first. let mut large = jbig2.clone(); large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); - assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page far taller than the image"); // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { let rgba = exported_rgba(&page(width, height, mask, None, true)).1; assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); } - // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. - for (rows, refused) in [(5_000u32, false), (5_001, true)] { - large[15..19].copy_from_slice(&rows.to_be_bytes()); - let doc = page(20_000, 4, &large, None, false); - assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); - if !refused { - assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); - } - } // Damaged data never panics. for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); @@ -2155,6 +2146,38 @@ fn jbig2_images_export_as_their_picture() { assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); } +/// A JBIG2 image behind another filter (ASCIIHexDecode) exports as its picture, as a fax image does, +/// with the `/JBIG2Globals` at its own place in a `/DecodeParms` array: globals that can't be read +/// there skip the image, and ones at the other filter's place aren't the image's. +#[test] +fn jbig2_images_behind_another_filter_export_as_their_picture() { + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = jbig2.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // With unreadable globals at place `at` of its /DecodeParms. + let page = |at: Option| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")])); + if let Some(at) = at { + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))))); + let mut entries = vec![Object::Null, Object::Null]; + entries[at] = Object::Dict(parms); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + Stream::from_raw(d, hex.clone()) + }); + doc + }; + assert_eq!(exported_rgba(&page(None)), ("png", picture.clone())); + assert_eq!(exported_rgba(&page(Some(0))).1, picture, "globals at ASCIIHexDecode's place"); + let out = extract_images(&page(Some(1)), &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "unreadable globals at its own place"); +} + /// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have /// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to /// one byte more skip the image, with the limit in the reason, though they take a few kilobytes @@ -2162,7 +2185,8 @@ fn jbig2_images_export_as_their_picture() { /// indirect `/DecodeParms`, in an array or not. #[test] fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { - const LIMIT: usize = 32 << 20; + const LIMIT: usize = pdfcraft_cos::MAX_JBIG2_GLOBALS; + assert_eq!(LIMIT, 32 << 20); let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); @@ -2206,6 +2230,118 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { let out = extract_images(&page(&over, form), &[0], 0); assert!(out.images.is_empty(), "globals over the limit, form {form}"); let why = &out.skipped.first().expect("the image is skipped").2; - assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); } } + +/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read +/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws. +#[test] +fn a_stencil_masks_decode_array_can_hold_references() { + use pdfcraft_cos::Document; + let export = |decode: &str| { + let pdf = 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 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\ + 4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\ + 5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ); + let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap(); + let mut export = crate::extract::extract_images(&doc, &[0], 1); + assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped); + export.images.remove(0).data + }; + let (inverted, identity) = (export("[1 0]"), export("[0 1]")); + assert_ne!(inverted, identity, "the two ways round differ"); + for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] { + assert_eq!(export(decode), inverted, "{decode}"); + } + for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] { + assert_eq!(export(decode), identity, "{decode}"); + } + // A reference that only leads back to itself holds no number, and an end that is missing or isn't + // a number leaves the default mapping, whichever end it is. + for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] { + assert_eq!(export(decode), identity, "{decode}"); + } + // Equal ends aren't the two ways round either, and a number too long to hold reads as infinite. + assert_eq!(export("[1 1]"), identity); + assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity); +} + +/// A mask of other proportions than its image is scaled to it, however far apart they are: the +/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the +/// process. The third would ask for 10 GB if it went across first, which is why the way round +/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the +/// image's size and larger than it, are scaled as they always were. +#[test] +fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + let gray = |w: i64, h: i64, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w)); + d.set(b"Height".to_vec(), Object::Int(h)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + Stream::from_raw(d, pdfcraft_filters::encode_flate(data)) + }; + let shapes = [ + (100_000usize, 1usize, 1usize, 100_000usize), + (100_000, 64, 64, 100_000), + (1, 100_000, 100_000, 1), + (64, 1, 1, 64), + (3, 2, 6, 4), + (3, 2, 3, 2), + (6, 4, 3, 2), + ]; + for (mask_w, mask_h, w, h) in shapes { + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h]))); + let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]); + image.dict.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(image)); + let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}")); + assert_eq!(name, "png"); + let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap(); + let mut rgba = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut rgba).unwrap(); + assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba)); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}"); + } +} + +/// The long way round is for proportions far apart: up to a quarter over the larger image the +/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes +/// out the same either way. +#[test] +fn proportions_up_to_the_limit_scale_as_they_did() { + let noise = |w: u32, h: u32| { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let px = (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + image::GrayImage::from_raw(w, h, px).unwrap() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let mask = noise(mw, mh); + let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle); + assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}"); + } + // A step past it goes the long way, which is how to tell. + let mask = noise(25, 15); + let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle); + assert_ne!(crate::extract::scaled(&mask, 10, 20), direct); + // A mask of its image's size comes through as it is. + let mask = noise(20, 10); + assert_eq!(crate::extract::scaled(&mask, 20, 10), mask); +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 89d9387989..2cd33a275d 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -24,3 +24,5 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } +proptest = { workspace = true } diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index ff570d5483..0d0213904b 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -7,9 +7,25 @@ use super::place::{ }; use super::{Document, EditError, Object, added, backdrop, helvetica_width}; +/// Commit one complete mark operation only after every selected page succeeds. The snapshot +/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table +/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed +/// page/resources/backdrop dictionaries and new objects, including the allocation cursor. +fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> { + let mut staged = doc.clone(); + apply(&mut staged)?; + *doc = staged; + Ok(()) +} + /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on -/// those pages are removed first (Acrobat's Replace Existing). +/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic: +/// on error, the document (including earlier selected pages) is unchanged. pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx)) +} + +fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if hf.text.iter().all(|t| t.trim().is_empty()) { @@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, Ok(()) } -/// Add a text watermark to `pages`. +/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement, +/// form one transaction; on error, the document is unchanged. pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace)) +} + +fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; let text = wm.text.trim(); @@ -201,8 +222,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac /// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a /// white backdrop a page paints itself is replaced first, so the background shows (see -/// [`backdrop`]); a picture backdrop (a scan) still hides it. +/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including +/// replacement and backdrop edits, form one transaction; on error, the document is unchanged. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace)) +} + +fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if replace { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index f66e7f24ee..2920a2a9ad 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,13 +4,28 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; use crate::text::content_streams; +/// The identity of a soft mask (`/SMask`) in the PDF graphics state. +#[derive(Clone, Debug)] +pub(crate) struct MaskIdentity { + // Share the owning ExtGState instead of copying an arbitrarily large inline mask on q/Q. + // Its complete SMask value retains S, TR, BC and every other mask parameter, not just G. + definition: Arc, + ctm: Matrix, +} + +impl PartialEq for MaskIdentity { + fn eq(&self, other: &Self) -> bool { + self.ctm == other.ctm && self.definition.as_dict().and_then(|d| d.get(b"SMask")) == other.definition.as_dict().and_then(|d| d.get(b"SMask")) + } +} + /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] pub struct PageImage { @@ -33,7 +48,7 @@ pub struct PageImage { pub height: u32, /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the /// operators of the page's streams joined ([`Pieces`]). - stream: usize, + pub stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], @@ -41,6 +56,11 @@ pub struct PageImage { } impl PageImage { + /// Whether an active clip was written by PdfCraft's image crop tool. + pub fn has_editor_crop(&self) -> bool { + self.clips.iter().any(|clip| clip.app_crop) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { @@ -72,6 +92,7 @@ impl PageImage { struct ClipPath { path: Arc, even_odd: bool, + app_crop: bool, } /// Image draws one page's inventory lists at most: a form drawn many times from other forms @@ -115,17 +136,39 @@ struct ImageState { placement: Placement, stack: Vec, path: BezPath, + subpath_start: Option, pending_clip: Option, + app_crop: bool, } impl ImageState { fn new(matrix: Matrix, clip: [f64; 4], clips: Vec) -> Self { - Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None } + Self { + placement: Placement { matrix, clip, clips }, + stack: Vec::new(), + path: BezPath::new(), + subpath_start: None, + pending_clip: None, + app_crop: false, + } } - fn point(&self, x: f64, y: f64) -> Point { + fn current_point(&self) -> Option { + match self.path.elements().last() { + Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) => Some(*p), + Some(kurbo::PathEl::QuadTo(_, p2) | kurbo::PathEl::CurveTo(_, _, p2)) => Some(*p2), + Some(kurbo::PathEl::ClosePath) => self.subpath_start, + None => None, + } + } + + fn has_current_point(&self) -> bool { + self.current_point().is_some() + } + + fn point(&self, x: f64, y: f64) -> Option { let (x, y) = self.placement.matrix.apply(x, y); - Point::new(x, y) + if x.is_finite() && y.is_finite() { Some(Point::new(x, y)) } else { None } } fn apply(&mut self, op: &Op) -> Result<(), EditError> { @@ -146,12 +189,18 @@ impl ImageState { } b"cm" => { if let Some(m) = op.nums::<6>() { - self.placement.matrix = Matrix(m).then(&self.placement.matrix); + let next_mat = Matrix(m).then(&self.placement.matrix); + if next_mat.0.iter().all(|v| v.is_finite()) { + self.placement.matrix = next_mat; + } } } b"m" => { - if let Some([x, y]) = op.nums::<2>() { - self.path.move_to(self.point(x, y)); + if let Some([x, y]) = op.nums::<2>() + && let Some(p) = self.point(x, y) + { + self.subpath_start = Some(p); + self.path.move_to(p); } } b"l" => { @@ -159,46 +208,57 @@ impl ImageState { // friends panic on an empty path by contract, so the segment is skipped, as the // renderer, MuPDF and pdftotext do (#3581). if let Some([x, y]) = op.nums::<2>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let Some(p) = self.point(x, y) { - self.path.line_to(self.point(x, y)); + self.path.line_to(p); } } b"c" => { if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(p2), Some(p3)) = (self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)) { - self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); + self.path.curve_to(p1, p2, p3); } } b"v" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) + && self.has_current_point() + && let (Some(p1), Some(p2)) = (self.point(x1, y1), self.point(x2, y2)) + && let Some(current) = self.current_point() { - self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2)); + self.path.curve_to(current, p1, p2); } } b"y" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(end)) = (self.point(x1, y1), self.point(x2, y2)) { - let end = self.point(x2, y2); - self.path.curve_to(self.point(x1, y1), end, end); + self.path.curve_to(p1, end, end); } } - // ClosePath with no subpath panics by the same contract (#3581). - b"h" if self.path.elements().last().is_some() => { - self.path.close_path(); - } - b"re" => { - if let Some([x, y, w, h]) = op.nums::<4>() { - self.path.move_to(self.point(x, y)); - self.path.line_to(self.point(x + w, y)); - self.path.line_to(self.point(x + w, y + h)); - self.path.line_to(self.point(x, y + h)); + b"h" => { + // ClosePath with no subpath panics by the same contract (#3581). + if self.has_current_point() { self.path.close_path(); } } + b"re" => { + if let Some([x, y, w, h]) = op.nums::<4>() + && let (Some(p0), Some(p1), Some(p2), Some(p3)) = + (self.point(x, y), self.point(x + w, y), self.point(x + w, y + h), self.point(x, y + h)) + { + self.subpath_start = Some(p0); + self.path.move_to(p0); + self.path.line_to(p1); + self.path.line_to(p2); + self.path.line_to(p3); + self.path.close_path(); + } + } + b"MP" if op.name(0) == Some(b"PdfCraftImageCrop") => self.app_crop = true, b"W" => self.pending_clip = Some(false), b"W*" => self.pending_clip = Some(true), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { @@ -207,9 +267,11 @@ impl ImageState { // follows the same rule, and its bounds use cubic extrema, not controls. let bounds = self.path.bounding_box(); self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]); - self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd }); + self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd, app_crop: self.app_crop }); } self.path = BezPath::new(); + self.subpath_start = None; + self.app_crop = false; } _ => {} } @@ -264,6 +326,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr } else { continue; }; + let rect = matrix.bbox(bounds); if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) || bounds[2] <= bounds[0] @@ -382,6 +445,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi Ok(out) } +/// The four stacking order arrangements. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArrangeOrder { + BringToFront, + SendToBack, + BringForward, + SendBackward, +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { @@ -394,6 +466,10 @@ pub enum ImageChange { /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, } /// The user-space transform that maps box `from` onto box `to` (move and resize). @@ -473,6 +549,1077 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } +pub(crate) fn is_entire_page_wrapped(ops: &[Op]) -> bool { + if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { + return false; + } + let mut depth: usize = 0; + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + if depth == 1 && i < ops.len().saturating_sub(1) { + return false; + } + depth = depth.saturating_sub(1); + } + } + depth == 0 +} + +/// The end (exclusive) of the sequence opened at `i`: just past the `close` that matches it, +/// counting the nested `opens`, or `end` when it never closes. +fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -> usize { + let mut depth: usize = 1; + for (j, op) in ops.iter().enumerate().take(end).skip(i.saturating_add(1)) { + if opens.iter().any(|o| op.is(o)) { + depth = depth.saturating_add(1); + } else if op.is(close) { + depth = depth.saturating_sub(1); + if depth == 0 { + return j.saturating_add(1); + } + } + } + end +} + +pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { + let mut units = Vec::new(); + let mut i = scan_start; + while i < scan_end { + let Some(op) = ops.get(i) else { break }; + let j = if op.is("BDC") || op.is("BMC") { + // A marked-content sequence is one unit up to its matching EMC (#3145), so Arrange moves + // it whole and never inserts into another one: its content belongs to its tag (ISO 32000-2 + // § 14.6). An unbalanced sequence runs to the end of the scan. + sequence_end(ops, i, scan_end, &["BDC", "BMC"], "EMC") + } else if op.is("q") { + sequence_end(ops, i, scan_end, &["q"], "Q") + } else if op.is("BT") { + sequence_end(ops, i, scan_end, &[], "ET") + } else if op.is("BI") { + sequence_end(ops, i, scan_end, &[], "EI") + } else if op.is("Do") || op.is("sh") { + i.saturating_add(1) + } else { + let mut j = i; + let mut found_end = false; + while j < scan_end { + let Some(next) = ops.get(j) else { break }; + if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + j = j.saturating_add(1); + found_end = true; + break; + } else if j > i + && matches!( + next.op.as_slice(), + b"q" | b"Q" | b"BT" | b"BI" | b"Do" | b"sh" | b"BDC" | b"BMC" | b"EMC" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + { + found_end = true; + break; + } + j = j.saturating_add(1); + } + if found_end { j } else { scan_end.max(i.saturating_add(1)) } + }; + units.push(i..j); + i = j; + } + units.retain(|u| { + ops.get(u.clone()).is_some_and(|slice| { + slice.iter().any(|op| { + matches!( + op.op.as_slice(), + b"Do" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + }) + }) + }); + units +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ExtractedScopeState { + pub clip_ops: Vec, + pub state_ops: Vec, + pub do_op: Op, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct EffectiveGState { + pub(crate) ca: Option, + pub(crate) ca_stroke: Option, + pub(crate) bm: Option>, + pub(crate) smask: Option, + // Printing parameters cannot generally be reset by a content-stream operator. Retain their + // exact values so Arrange refuses a destination whose state it cannot reconstruct. + print_params: std::collections::BTreeMap, std::rc::Rc>, + rendering_intent: Option>, +} + +impl EffectiveGState { + pub(crate) fn visually_differs(&self, other: &EffectiveGState) -> bool { + (self.ca.unwrap_or(1.0) - other.ca.unwrap_or(1.0)).abs() > 1e-4 + || (self.ca_stroke.unwrap_or(1.0) - other.ca_stroke.unwrap_or(1.0)).abs() > 1e-4 + || self.bm.as_deref().unwrap_or(b"Normal") != other.bm.as_deref().unwrap_or(b"Normal") + || self.smask != other.smask + || self.print_params != other.print_params + } + + #[cfg(test)] + pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, ctm: Matrix) { + self.merge_shared(&Arc::new(Object::Dict(dict.clone())), doc, ctm); + } + + fn merge_shared(&mut self, definition: &Arc, doc: &Document, ctm: Matrix) { + let Some(dict) = definition.as_dict() else { return }; + for key in [b"TR".as_slice(), b"TR2", b"OP", b"op", b"OPM", b"BG", b"BG2", b"UCR", b"UCR2", b"HT"] { + if let Some(value) = dict.get(key) { + self.print_params.insert(key.to_vec(), std::rc::Rc::new(value.clone())); + } + } + if let Some(intent) = dict.get(b"RI").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)) { + self.rendering_intent = Some(intent); + } + if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca = Some(val); + } + if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca_stroke = Some(val); + } + if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) { + if let Some(name) = bm_obj.as_name() { + self.bm = Some(name.to_vec()); + } else if let Some(arr) = bm_obj.as_array() + && let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec())) + { + self.bm = Some(first_name); + } + } + if let Some(raw_smask) = dict.get(b"SMask") { + let disabled = raw_smask.as_name() == Some(b"None") || raw_smask.as_ref().is_some_and(|r| doc.get(r).as_name() == Some(b"None")); + self.smask = (!disabled).then(|| MaskIdentity { definition: Arc::clone(definition), ctm }); + } + } +} + +const MAX_ACTIVE_GS_OPS: usize = 128; + +type ActiveGsOp = (Vec, Op, Matrix); +type ActiveGsOps = std::rc::Rc>; + +#[derive(Clone, Default)] +struct ExtendedGStateTracker { + stack: Vec<(EffectiveGState, ActiveGsOps)>, + current_state: EffectiveGState, + active_gs_ops: ActiveGsOps, + definitions: std::collections::HashMap, Arc>, +} + +impl ExtendedGStateTracker { + fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> { + match op.op.as_slice() { + b"ri" => self.current_state.rendering_intent = op.name(0).map(<[u8]>::to_vec), + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push((self.current_state.clone(), std::rc::Rc::clone(&self.active_gs_ops))); + } + b"Q" => { + if let Some((prev_state, prev_ops)) = self.stack.pop() { + self.current_state = prev_state; + self.active_gs_ops = prev_ops; + } + } + b"gs" => { + let valid_gs = op.name(0).and_then(|name| { + let definition = self.definitions.get(name).cloned().or_else(|| { + let definition = doc.resolve(ext_gstates.get(name)?); + definition.as_dict()?; + self.definitions.insert(name.to_vec(), Arc::clone(&definition)); + Some(definition) + })?; + Some((name, definition)) + }); + if let Some((name, definition)) = valid_gs { + self.current_state.merge_shared(&definition, doc, ctm); + let mut next = (*self.active_gs_ops).clone(); + if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) { + next.remove(pos); + } + if next.len() < MAX_ACTIVE_GS_OPS { + next.push((name.to_vec(), op.clone(), ctm)); + } + self.active_gs_ops = std::rc::Rc::new(next); + } + } + _ => {} + } + Ok(()) + } +} + +/// The drawing-state operators besides colour that a moved unit restores, in the order it writes them. +const DRAWING_PARAMS: [&str; 14] = ["w", "J", "j", "M", "d", "ri", "i", "Tc", "Tw", "Tz", "TL", "Tr", "Ts", "Tf"]; + +#[derive(Clone, Default, Debug, PartialEq)] +struct DrawingState { + non_stroking_color: Option, + stroking_color: Option, + /// The last operator of each of [`DRAWING_PARAMS`]. + params: [Option>; DRAWING_PARAMS.len()], +} + +// Save/restore shares operand storage, including when only the current colour changes under q. +type SharedColorOps = std::rc::Rc>>; + +/// Keep the explicit or implicit colour-space selector before `sc`/`scn` (or `SC`/`SCN`). +fn set_color(slot: &mut Option, op: &Op, space: &str) { + match slot { + Some(ops) if ops.first().is_some_and(|o| o.is(space) || matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K")) => { + let ops = std::rc::Rc::make_mut(ops); + ops.truncate(1); + ops.push(std::rc::Rc::new(op.clone())); + } + _ => *slot = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + } +} + +#[derive(Clone, Default, Debug)] +struct DrawingStateTracker { + stack: Vec, + current: DrawingState, +} + +impl DrawingStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current = prev; + } + } + b"g" | b"rg" | b"k" | b"cs" => self.current.non_stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"sc" | b"scn" => set_color(&mut self.current.non_stroking_color, op, "cs"), + b"G" | b"RG" | b"K" | b"CS" => self.current.stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"SC" | b"SCN" => set_color(&mut self.current.stroking_color, op, "CS"), + _ => { + if let Some(slot) = DRAWING_PARAMS.iter().position(|p| op.is(p)).and_then(|k| self.current.params.get_mut(k)) { + *slot = Some(std::rc::Rc::new(op.clone())); + } + } + } + Ok(()) + } +} + +/// Initial line style, rendering intent and text-state parameters. +fn initial_param(name: &str) -> Option { + use pdfcraft_content::num; + let operands = match name { + "w" => vec![num(1.0)], + "J" | "j" => vec![num(0.0)], + "M" => vec![num(10.0)], + "d" => vec![Object::Array(vec![]), num(0.0)], + "ri" => vec![Object::name("RelativeColorimetric")], + "Tc" | "Tw" | "TL" | "Tr" | "Ts" => vec![num(0.0)], + "Tz" => vec![num(100.0)], + _ => return None, + }; + Some(Op::new(name, operands)) +} + +/// The drawing state the image had at `source` that differs at `dest`, unless the moved unit's own +/// state operators (`extracted_state_ops`) set it. +fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec { + let sets = |names: &[&str]| extracted_state_ops.iter().any(|o| names.iter().any(|n| o.is(n))); + let mut ops = Vec::new(); + for (own, source, dest, black) in [ + (sets(&["g", "rg", "k", "cs"]), &source.non_stroking_color, &dest.non_stroking_color, "g"), + (sets(&["G", "RG", "K", "CS"]), &source.stroking_color, &dest.stroking_color, "G"), + ] { + match (own, source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(color), _) => ops.extend(color.iter().map(|op| (**op).clone())), + (false, None, Some(_)) => ops.push(Op::new(black, vec![pdfcraft_content::num(0.0)])), + } + } + let mut text_ops = Vec::new(); + for ((name, source), dest) in DRAWING_PARAMS.iter().zip(&source.params).zip(&dest.params) { + let output = if matches!(*name, "Tc" | "Tw" | "Tz" | "TL" | "Tr" | "Ts" | "Tf") { &mut text_ops } else { &mut ops }; + match (sets(&[name]), source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(op), _) => output.push((**op).clone()), + (false, None, Some(_)) => output.extend(initial_param(name)), + } + } + if !text_ops.is_empty() { + // Forms inherit text state, even though each BT resets its text matrices. + ops.push(Op::new("BT", vec![])); + ops.extend(text_ops); + ops.push(Op::new("ET", vec![])); + } + ops +} + +/// The operators that set `clip` again, in user space. [`ImageState`] builds clips only from +/// finite points with `m`, `l`, `c` and `h`, so `None` (a quadratic segment) does not happen. +fn clip_path_to_ops(clip: &ClipPath) -> Option> { + let mut ops = Vec::new(); + if clip.app_crop { + ops.push(crop_marker()); + } + for el in clip.path.elements() { + let (name, points) = match el { + kurbo::PathEl::MoveTo(p) => ("m", vec![*p]), + kurbo::PathEl::LineTo(p) => ("l", vec![*p]), + kurbo::PathEl::CurveTo(p1, p2, p3) => ("c", vec![*p1, *p2, *p3]), + kurbo::PathEl::ClosePath => ("h", Vec::new()), + kurbo::PathEl::QuadTo(..) => return None, + }; + ops.push(Op::new(name, points.iter().flat_map(|p| [pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]).collect())); + } + if !ops.is_empty() { + ops.push(Op::new(if clip.even_odd { "W*" } else { "W" }, vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> { + if target >= ops.len() || start > target { + return Ok(None); + } + let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + if start == target { + return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), do_op: target_op })); + } + let mut clip_ops = Vec::new(); + let mut state_ops = Vec::new(); + let mut pending_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut active_path = false; + let mut depth: usize = 0; + let mut matrix = initial_ctm; + let mut matrix_at_clip = None; + + let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { start.saturating_add(1) } else { start }; + + for i in scan_start..target { + let Some(op) = ops.get(i) else { + return Ok(None); + }; + match op.op.as_slice() { + b"q" => { + if depth >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + depth = depth.saturating_add(1); + } + b"Q" => { + if depth == 0 { + return Ok(None); + } + depth = depth.saturating_sub(1); + } + _ if depth > 0 => {} + b"cm" => { + if active_path { + return Ok(None); + } + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => { + active_path = true; + pending_path.push(op.clone()); + } + b"W" | b"W*" => { + if !active_path { + return Ok(None); + } + pending_clip = true; + pending_path.push(op.clone()); + } + b"n" => { + if pending_clip { + if matrix_at_clip.is_none() { + matrix_at_clip = Some(matrix); + } else if matrix_at_clip != Some(matrix) { + return Ok(None); + } + clip_ops.extend(std::mem::take(&mut pending_path)); + clip_ops.push(op.clone()); + pending_clip = false; + active_path = false; + } else { + pending_path.clear(); + active_path = false; + } + } + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if pending_clip { + return Ok(None); + } + pending_path.clear(); + active_path = false; + } + b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => { + state_ops.push(op.clone()); + } + b"BT" => { + return Ok(None); + } + _ => {} + } + } + + if depth != 0 || active_path || pending_clip { + return Ok(None); + } + + if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) { + return Ok(None); + } + + Ok(Some(ExtractedScopeState { clip_ops, state_ops, do_op: target_op })) +} + +/// The unit `order` puts the image's unit `target_idx` next to, and whether after it. An image that +/// shares its unit with other paint at the limit moves within that unit, past or behind the paint. +fn target_destination_unit<'a>( + units: &'a [std::ops::Range], + target_idx: usize, + target_unit: &'a std::ops::Range, + order: ArrangeOrder, + has_other_paint: bool, +) -> Result<(&'a std::ops::Range, bool), EditError> { + let forward = matches!(order, ArrangeOrder::BringToFront | ArrangeOrder::BringForward); + let refused = || EditError::Invalid(if forward { "the image is already in front" } else { "the image is already at the back" }.into()); + let at_limit = if forward { target_idx.saturating_add(1) >= units.len() } else { target_idx == 0 }; + if at_limit { + return if has_other_paint { Ok((target_unit, forward)) } else { Err(refused()) }; + } + let dest = match order { + ArrangeOrder::BringToFront => units.last(), + ArrangeOrder::SendToBack => units.first(), + ArrangeOrder::BringForward => units.get(target_idx.saturating_add(1)), + ArrangeOrder::SendBackward => target_idx.checked_sub(1).and_then(|i| units.get(i)), + }; + dest.map(|u| (u, forward)).ok_or_else(refused) +} + +/// A moved unit must not borrow a graphics or marked-content boundary from its neighbours. +fn balanced_arrange_span(ops: &[Op], range: std::ops::Range) -> bool { + let Some(slice) = ops.get(range) else { return false }; + let (mut graphics, mut marked) = (0usize, 0usize); + for op in slice { + let depth = match op.op.as_slice() { + b"q" => { + graphics = graphics.saturating_add(1); + continue; + } + b"BDC" | b"BMC" => { + marked = marked.saturating_add(1); + continue; + } + b"Q" => &mut graphics, + b"EMC" => &mut marked, + _ => continue, + }; + let Some(next) = depth.checked_sub(1) else { return false }; + *depth = next; + } + graphics == 0 && marked == 0 +} + +/// What Arrange takes out: the byte span, the op where the image's own scope state starts, and the +/// ops `start..end` removed. That is the image's own `q … Q` scope inside its unit, else the `Do` +/// alone when the unit paints something else too, else the whole unit. +fn determine_arrange_spans( + ops: &[Op], + target: usize, + target_unit: &std::ops::Range, + has_other_paint: bool, + marked_unit: bool, +) -> Result<(std::ops::Range, usize, usize, usize), EditError> { + let scope = image_scopes(ops, target, "arranged") + .ok() + .and_then(|s| s.isolated) + .filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end); + let unit_last = target_unit.end.saturating_sub(1); + let (first, scope_start, last) = match scope { + Some((start, end, _)) if has_other_paint || !marked_unit => (start, start, end), + _ if has_other_paint => (target, target, target), + // A marked-content unit holding only this image moves whole, with its BDC…EMC. + // The whole sequence is removed; the image's state is rebuilt from the ops inside it. + _ if marked_unit => (target_unit.start, target_unit.start.saturating_add(1).min(target), unit_last), + _ => (target_unit.start, target_unit.start, unit_last), + }; + if !balanced_arrange_span(ops, first..last.saturating_add(1)) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + let missing = || EditError::Invalid("the image content unit could not be located".into()); + let start = ops.get(first).ok_or_else(missing)?.span.start; + let end = ops.get(last).ok_or_else(missing)?.span.end; + Ok((start..end, scope_start, first, last.saturating_add(1))) +} + +fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec, bool) { + let mut q_depth = 0usize; + let mut leaking_ops = Vec::new(); + let mut pending_clip_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut has_leaking_clip = false; + for op in ops.get(rem_start..rem_end).unwrap_or_default() { + match op.op.as_slice() { + b"q" => q_depth = q_depth.saturating_add(1), + b"Q" => q_depth = q_depth.saturating_sub(1), + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" if q_depth == 0 => { + pending_clip_path.push(op.clone()); + } + b"W" | b"W*" if q_depth == 0 => { + if !pending_clip_path.is_empty() { + pending_clip = true; + pending_clip_path.push(op.clone()); + } + } + b"n" if q_depth == 0 => { + if pending_clip { + pending_clip_path.push(op.clone()); + leaking_ops.extend(std::mem::take(&mut pending_clip_path)); + has_leaking_clip = true; + pending_clip = false; + } else { + pending_clip_path.clear(); + } + } + b"rg" | b"RG" | b"g" | b"G" | b"k" | b"K" | b"cs" | b"CS" | b"sc" | b"scn" | b"SC" | b"SCN" | b"w" | b"J" | b"j" | b"M" | b"d" + | b"ri" | b"i" | b"gs" | b"Tf" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Tr" | b"Ts" + if q_depth == 0 => + { + leaking_ops.push(op.clone()); + } + b"cm" if q_depth == 0 => { + leaking_ops.push(op.clone()); + } + _ => { + if q_depth == 0 && !op.is("BDC") && !op.is("BMC") && !op.is("EMC") && !pending_clip { + pending_clip_path.clear(); + } + } + } + } + (leaking_ops, has_leaking_clip) +} + +fn build_relocated_unit_ops( + marked_open: Option<&Op>, + source_gs_ops: &[(Op, Matrix)], + inherited_drawing_ops: &[Op], + extracted: ExtractedScopeState, + dest_ctm: Matrix, + draw_matrix: [f64; 6], + has_closing_emc: bool, +) -> Result, EditError> { + let mut unit_ops = Vec::new(); + if let Some(open) = marked_open { + unit_ops.push(open.clone()); + } + unit_ops.push(Op::new("q", vec![])); + let mut current_ctm = dest_ctm; + + for (gs_op, target_ctm) in source_gs_ops { + if *target_ctm != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = target_ctm.then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + current_ctm = *target_ctm; + } + } + unit_ops.push(gs_op.clone()); + } + + unit_ops.extend(inherited_drawing_ops.iter().cloned()); + + let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs")); + unit_ops.extend(non_gs_ops); + + if !extracted.clip_ops.is_empty() { + if current_ctm != Matrix::IDENTITY { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + unit_ops.push(Op::new("cm", current_inv.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.extend(extracted.clip_ops); + if Matrix(draw_matrix) != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", draw_matrix.map(pdfcraft_content::num).to_vec())); + } + } else if Matrix(draw_matrix) != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = Matrix(draw_matrix).then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + } + } + unit_ops.push(extracted.do_op); + unit_ops.push(Op::new("Q", vec![])); + if has_closing_emc { + unit_ops.push(Op::new("EMC", vec![])); + } + Ok(unit_ops) +} + +/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`. +fn arrange_image( + doc: &mut Document, + p: &pdfcraft_model::Page, + all: &[(Object, Vec)], + img: &PageImage, + order: ArrangeOrder, +) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let target_op = ops.get(target).filter(|op| op.is("Do")).ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let is_wrapped = is_entire_page_wrapped(&ops); + let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) }; + let units = content_units(&ops, scan_start, scan_end); + let Some((target_idx, target_unit)) = units.iter().enumerate().find(|(_, u)| u.contains(&target)) else { + return Err(EditError::Invalid("the image content unit could not be located".into())); + }; + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) + }); + if units.len() <= 1 && !has_other_paint { + return Err(EditError::Invalid("the page has only one content unit to arrange".into())); + } + + let (dest_unit, insert_after) = target_destination_unit(&units, target_idx, target_unit, order, has_other_paint)?; + if !balanced_arrange_span(&ops, target_unit.clone()) || !balanced_arrange_span(&ops, dest_unit.clone()) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + + // A marked-content sequence that holds other content cannot be split without generating + // a new structure element and MCID, so we refuse to rearrange it to avoid leaving the image untagged. + // If the sequence holds only this image, the whole marked unit is moved intact. + let marked_unit = ops.get(target_unit.start).is_some_and(|o| o.is("BDC") || o.is("BMC")); + let marked_depth = ops.iter().take(target).fold(0usize, |depth, op| { + if op.is("BDC") || op.is("BMC") { + depth.saturating_add(1) + } else if op.is("EMC") { + depth.saturating_sub(1) + } else { + depth + } + }); + if marked_depth > usize::from(marked_unit) { + return Err(EditError::Invalid("cannot rearrange an image out of a nested marked-content sequence".into())); + } + if marked_unit && has_other_paint { + return Err(EditError::Invalid("cannot rearrange an image that shares a marked-content sequence with other content".into())); + } + + let (mut target_span, scope_start, rem_start, rem_end) = determine_arrange_spans(&ops, target, target_unit, has_other_paint, marked_unit)?; + + let missing = || EditError::Invalid("the destination content unit could not be located".into()); + let (dest_pos, scan_target) = if insert_after { + (dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).ok_or_else(missing)?.span.end, dest_unit.end) + } else { + (ops.get(dest_unit.start).ok_or_else(missing)?.span.start, dest_unit.start) + }; + + // An added item owns its entire stream: editing or deleting it replaces that stream. + // Insert ordinary artwork next to the complete owned unit in a separate stream instead. + let destination_piece = if insert_after { + dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).map(|op| pieces.piece_of(op.span.end.saturating_sub(1))) + } else { + ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start)) + }; + let owned_destination = destination_piece + .filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some()))); + if let Some(piece) = owned_destination { + let whole_unit = ops.iter().enumerate().all(|(index, op)| { + let (first, last) = pieces.pieces_of(op); + if dest_unit.contains(&index) { first == piece && last == piece } else { piece < first || piece > last } + }); + if !whole_unit || img.stream == piece { + return Err(EditError::Invalid("cannot arrange an image inside an added item's content stream".into())); + } + } + + let (leaking_ops, has_leaking_clip) = scan_leaking_ops(&ops, rem_start, rem_end); + + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); + let mut drawing_tracker = DrawingStateTracker::default(); + let mut source_effective_state = EffectiveGState::default(); + let mut source_gs_ops = Vec::new(); + let mut source_drawing_state = DrawingState::default(); + let mut dest_effective_state = EffectiveGState::default(); + let mut dest_drawing_state = DrawingState::default(); + let mut dest_ctm = Matrix::IDENTITY; + let mut scope_start_ctm = Matrix::IDENTITY; + let mut outer_clips = Vec::new(); + let mut dest_clips = Vec::new(); + let mut ctm_before_removal = Matrix::IDENTITY; + let mut ctm_after_removal = Matrix::IDENTITY; + + let max_scan = scan_target.max(target.saturating_add(1)).max(scope_start.saturating_add(1)).max(rem_end); + for (i, op) in ops.iter().enumerate() { + if i == rem_start { + ctm_before_removal = state.placement.matrix; + } + if i == scope_start { + scope_start_ctm = state.placement.matrix; + outer_clips = state.placement.clips.clone(); + } + if i == target { + source_effective_state = tracker.current_state.clone(); + source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect(); + source_drawing_state = drawing_tracker.current.clone(); + } + if i == scan_target { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if i >= max_scan { + break; + } + state.apply(op)?; + tracker.step(op, &ext_gstates, doc, state.placement.matrix)?; + drawing_tracker.step(op)?; + if op.is("gs") + && op.name(0).and_then(|name| tracker.definitions.get(name)).and_then(|gs| gs.as_dict()).is_some_and(|gs| gs.get(b"Font").is_some()) + && let Some(font) = DRAWING_PARAMS.iter().position(|name| *name == "Tf").and_then(|index| drawing_tracker.current.params.get_mut(index)) + { + // The replayed gs selects its font; do not overwrite it with an older direct Tf. + *font = None; + } + if op.is("gs") + && let Some(intent) = &tracker.current_state.rendering_intent + { + drawing_tracker.step(&Op::new("ri", vec![Object::Name(intent.clone())]))?; + } + if i == rem_end.saturating_sub(1) { + ctm_after_removal = state.placement.matrix; + } + } + if scan_target >= ops.len() { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if dest_ctm.invert().is_none() { + return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into())); + } + + let owned_crop = ops.get(scope_start..target).unwrap_or_default().iter().any(|op| op.is("MP") && op.name(0) == Some(b"PdfCraftImageCrop")); + let extracted_scope = if owned_crop { None } else { extract_scope_state(&ops, scope_start, target, scope_start_ctm)? }; + let mut extracted = match extracted_scope { + Some(ext) => ext, + None if scope_start != target => { + if !marked_unit || has_other_paint { + target_span = target_op.span.clone(); + } + extract_scope_state(&ops, target, target, Matrix(img.draw_matrix))? + .ok_or_else(|| EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()))? + } + None => { + return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into())); + } + }; + + // The clips the moved unit draws itself: all of the image's when they are rebuilt from + // `img.clips`, else those its own scope set after the clips around it. + let all_clips = extracted.clip_ops.is_empty(); + let reemitted = if all_clips { + img.clips.as_slice() + } else if img.clips.starts_with(&outer_clips) { + img.clips.get(outer_clips.len()..).unwrap_or_default() + } else { + return Err(EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into())); + }; + // At the destination it is clipped by the clips there and the ones it draws. Moving past a + // clip the image isn't under (or out from under one it is) would change what part shows. + if !dest_clips.iter().all(|c| img.clips.contains(c)) || !img.clips.iter().all(|c| dest_clips.contains(c) || reemitted.contains(c)) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image past a clipping path would change what part of it shows".into(), + )); + } + + if all_clips { + for clip in &img.clips { + let ops = clip_path_to_ops(clip) + .ok_or_else(|| EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into()))?; + extracted.clip_ops.extend(ops); + } + } + + let has_inherited_clip = !img.clips.is_empty() && extracted.clip_ops.is_empty(); + if has_inherited_clip { + return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); + } + + let mut reconstructed_state = dest_effective_state.clone(); + for (op, ctm) in &source_gs_ops { + if let Some(name) = op.name(0) + && let Some(definition) = tracker.definitions.get(name) + { + reconstructed_state.merge_shared(definition, doc, *ctm); + } + } + + if reconstructed_state.visually_differs(&source_effective_state) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image would alter opacity, blend mode, soft mask, or printing parameters".into(), + )); + } + + let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None }; + let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC")); + let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops); + let unit_ops = + build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; + + let mut serialized = Vec::new(); + serialized.push(b'\n'); + serialized.extend_from_slice(&serialize_ops(&unit_ops)); + serialized.push(b'\n'); + + let leaking = if let Some(inv) = ctm_before_removal.invert() { ctm_after_removal.then(&inv) } else { Matrix::IDENTITY }; + if leaking != Matrix::IDENTITY && has_leaking_clip { + return Err(EditError::Invalid("unsupported graphics state: moving the image would alter clipping path transform".into())); + } + let mut leak_ops = leaking_ops; + if leaking != Matrix::IDENTITY && leak_ops.iter().all(|o| !o.is("cm")) { + leak_ops.insert(0, Op::new("cm", leaking.0.map(pdfcraft_content::num).to_vec())); + } + let removal_replacement = if !leak_ops.is_empty() { + let mut s = Vec::new(); + s.push(b'\n'); + s.extend_from_slice(&serialize_ops(&leak_ops)); + s.push(b'\n'); + s + } else { + Vec::new() + }; + + if let Some(piece) = owned_destination { + let changed = pieces.splice([(target_span, removal_replacement)]); + let contents = crate::text::rewritten_contents(doc, all, changed); + let mut list = match contents { + Object::Array(list) => list, + one => vec![one], + }; + let insertion = piece.saturating_add(usize::from(insert_after)); + if insertion > list.len() { + return Err(EditError::Invalid("the destination content stream is missing".into())); + } + let relocated = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &serialized))); + list.insert(insertion, Object::Ref(relocated)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + return Ok(()); + } + + let mut edits = Vec::new(); + if dest_pos >= target_span.end { + edits.push((target_span, removal_replacement)); + edits.push((dest_pos..dest_pos, serialized)); + } else if dest_pos <= target_span.start { + edits.push((dest_pos..dest_pos, serialized)); + edits.push((target_span, removal_replacement)); + } else { + return Err(EditError::Invalid("cannot arrange image into overlapping content range".into())); + } + + let changed = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// The saved graphics-state scopes (`q … Q`) around the `Do` at `target` that paint nothing else, +/// each as `(q, Q, CTM before the q)`. +struct ImageScopes { + /// The innermost one inside which every clip on the image is set. + isolated: Option<(usize, usize, Matrix)>, + /// The outermost one. It holds all of the image's private clips, but not a clip set outside it. + outermost: Option<(usize, usize, Matrix)>, + /// The CTM at the `Do`, and whether a clip applies there. + placement: Matrix, + clipped: bool, +} + +impl ImageScopes { + /// The saved graphics-state scope containing just this image and its crop, or the `Do` alone + /// when nothing clips it. Applying a transform before that scope moves the actual clipping path + /// as well as the pixels. A shared clip must never be moved along with unrelated artwork or text. + /// + /// Under a clip set outside every scope that holds only this image (a report or table drawing + /// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active + /// clip. The outermost scope holding only the image is used then: it contains all of the + /// image's private clips, which move with it, while the outer clip is shared and stays. + fn transform_scope(&self, target: usize) -> Option<(usize, usize, Matrix)> { + if !self.clipped { + return Some((target, target, self.placement)); + } + self.isolated.or(self.outermost) + } +} + +/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. +fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + let mut outermost = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_path { + let found = (scope.start, index, scope.matrix); + if !scope.inherited_clip { + isolated.get_or_insert(found); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some(found); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + path = scope.inherited_path; + pending_clip = false; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid(format!("an image inside a text object cannot be {done}"))); + } + at_image = Some((matrix, clipped)); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + Ok(ImageScopes { isolated, outermost, placement, clipped }) +} + +/// A marked-content point identifies only the clipping path written by this editor. +/// It does not open a marked-content sequence or change the document's structure tags. +fn crop_marker() -> Op { + Op::new("MP", vec![Object::Name(b"PdfCraftImageCrop".to_vec())]) +} + +fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec> { + let mut ranges = Vec::new(); + for (index, op) in ops.iter().enumerate().take(target).skip(start) { + if !op.is("MP") || op.name(0) != Some(b"PdfCraftImageCrop") { + continue; + } + let path_start = index.saturating_add(1); + let mut end = path_start; + while end < target && ops.get(end).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + end = end.saturating_add(1); + } + if end > path_start && ops.get(end).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(end.saturating_add(1)).is_some_and(|o| o.is("n")) { + ranges.push(index..end.saturating_add(2)); + } + } + ranges +} + +/// Remove only crops written by PdfCraft, preserving the document author's clipping paths. +fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec)], img: &PageImage) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let scope = image_scopes(&ops, target, "cropped")?; + let Some((start, _, _)) = scope.transform_scope(target) else { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + }; + let crops = app_crop_ranges(&ops, start, target); + if crops.is_empty() { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index)))); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -490,8 +1637,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Arrange(order) => { + return arrange_image(doc, &p, &all, &img, *order); + } + ImageChange::RemoveCrop => { + return remove_image_crop(doc, &p, &all, &img); + } ImageChange::Crop(keep) => { - return crop_image(doc, &p, &all, &img, *keep); + return crop_image_impl(doc, &p, &all, &img, *keep); } ImageChange::Replace(r) => { if img.is_form { @@ -534,94 +1687,6 @@ pub(crate) fn selectable_images( .collect()) } -/// Find the saved graphics-state scope containing just this image and its crop. Applying -/// the transform before that scope moves the actual clipping path as well as the pixels. -/// A shared clip must never be moved along with unrelated artwork or text. -/// -/// Under a clip set outside every scope that holds only this image (a report or table drawing -/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active -/// clip. The outermost scope holding only the image is used then: it contains all of the -/// image's private clips, which move with it, while the outer clip is shared and stays. -fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { - struct Scope { - start: usize, - matrix: Matrix, - inherited_clip: bool, - inherited_path: bool, - selected: bool, - other_paint: bool, - } - let mut stack: Vec = Vec::new(); - let mut matrix = Matrix::IDENTITY; - let mut clipped = false; - let mut path = false; - let mut pending_clip = false; - let mut text = false; - let mut at_image = None; - let mut isolated = None; - let mut outermost = None; - for (index, op) in ops.iter().enumerate() { - match op.op.as_slice() { - b"q" => { - if stack.len() >= 256 { - return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into())); - } - stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); - } - b"Q" => { - if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_path { - if !scope.inherited_clip { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); - } - // Scopes close inner first, so the last one recorded is the outermost. - outermost = Some((scope.start, index, scope.matrix)); - } - matrix = scope.matrix; - clipped = scope.inherited_clip; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - matrix = Matrix(m).then(&matrix); - } - } - b"BT" => text = true, - b"ET" => text = false, - b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, - b"W" | b"W*" => pending_clip = true, - _ => {} - } - if index == target { - if text { - return Err(EditError::Invalid("an image inside a text object cannot be transformed".into())); - } - at_image = Some((matrix, clipped)); - for scope in &mut stack { - scope.selected = true; - } - } else if matches!( - op.op.as_slice(), - b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" - ) { - for scope in &mut stack { - scope.other_paint = true; - } - } - if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { - clipped |= pending_clip; - pending_clip = false; - path = false; - } - } - let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - if !clipped { - return Ok(Some((target, target, placement))); - } - Ok(isolated.or(outermost)) -} - fn transform_image( doc: &mut Document, page: &pdfcraft_model::Page, @@ -640,12 +1705,14 @@ fn transform_image( } // A private path crop moves with its image regardless of intervening state operators. // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let local = placement.then(&Matrix(transform)).then(&inverse); if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform is too large".into())); } + // Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added): // change that `cm` in place. The CTM inside is `local · cm · outer` either way. if let Some((at, cm)) = enclosing_transform(&ops, start, end) { @@ -686,13 +1753,16 @@ fn transform_image( /// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in /// that scope's coordinates as the box's four corners, so a turned image crops to an upright /// box too and a later transform of the scope moves the crop with the pixels. -fn crop_image( +fn crop_image_impl( doc: &mut Document, page: &pdfcraft_model::Page, streams: &[(Object, Vec)], image: &PageImage, keep: [f64; 4], ) -> Result<(), EditError> { + if image.is_form { + return Err(EditError::Invalid("cropping grouped Form artwork is not supported".into())); + } if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); } @@ -700,36 +1770,53 @@ fn crop_image( if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { return Err(EditError::Invalid("the crop box has no area".into())); } + if intersection(image.rect, [x0, y0, x1, y1]).is_none() { + return Err(EditError::Invalid("the crop box does not intersect the image".into())); + } + let pieces = joined(streams); let ops = pieces.parse().ops; let target = image.op; if !ops.get(target).is_some_and(|op| op.is("Do")) { return Err(EditError::Invalid("the image operation could not be located".into())); } - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); } - let clip: Vec = corners - .iter() - .enumerate() - .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) - .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) - .collect(); - // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to - // the new one, so a crop can grow again as well as shrink. - let old_clip: Option> = (start != end) - .then(|| { - let mut k = start + 1; - while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { - k += 1; - } - (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) - .then_some(start + 1..k + 2) - }) - .flatten(); + let is_axis_aligned = ((corners[0].1 - corners[1].1).abs() < 1e-4 + && (corners[1].0 - corners[2].0).abs() < 1e-4 + && (corners[2].1 - corners[3].1).abs() < 1e-4 + && (corners[3].0 - corners[0].0).abs() < 1e-4) + || ((corners[0].0 - corners[1].0).abs() < 1e-4 + && (corners[1].1 - corners[2].1).abs() < 1e-4 + && (corners[2].0 - corners[3].0).abs() < 1e-4 + && (corners[3].1 - corners[0].1).abs() < 1e-4); + let mut clip: Vec = if is_axis_aligned { + let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min); + let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min); + let max_y = corners.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max); + vec![ + Op::new("re", vec![Object::Real(min_x), Object::Real(min_y), Object::Real(max_x - min_x), Object::Real(max_y - min_y)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + ] + } else { + corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect() + }; + clip.insert(0, crop_marker()); + // Replace only our identified crop; an author's path must remain intact. + let old_crops = app_crop_ranges(&ops, start, target); + let insert_at = start.saturating_add(1); let changed = crate::text::splice(&pieces, &ops, |index| { if start == end && index == target { // No scope of its own yet: one around the drawing, with the crop inside. @@ -738,10 +1825,10 @@ fn crop_image( out.extend(ops.get(index).cloned()); out.push(Op::new("Q", vec![])); (out, false) - } else if start != end && index == start + 1 { + } else if start != end && index == insert_at { // Inside the scope's `q`, before whatever it draws (in place of its old crop). - (clip.clone(), old_clip.is_none()) - } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (clip.clone(), !old_crops.iter().any(|r| r.contains(&index))) + } else if old_crops.iter().any(|r| r.contains(&index)) { (Vec::new(), false) } else { (Vec::new(), true) @@ -787,6 +1874,42 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } // Images alone in their own `q re W n cm Do Q` take the clip with them. let mut clips = std::collections::BTreeMap::new(); + let mut crop_starts = std::collections::BTreeMap::new(); + let mut crop_ends = std::collections::BTreeSet::new(); + let mut folds = std::collections::BTreeMap::new(); + for image in images.iter().filter(|image| image.has_editor_crop()) { + let Some(&(_, _, offset)) = plans.get(&image.op) else { continue }; + let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else { + return Err(EditError::Invalid("the image crop cannot be moved independently".into())); + }; + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?; + let local = placement.then(&Matrix::translate(offset[0], offset[1])).then(&inverse); + if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + let leading_transform = || { + let at = start.checked_add(1)?; + if start >= end || !ops.get(start)?.is("q") || !ops.get(at)?.is("cm") { + return None; + } + ops.get(at)?.nums::<6>().map(|m| (at, Matrix(m))) + }; + // An enclosing cm is before the captured placement; an inside-scope cm is after it. + // Their multiplication orders differ when that cm scales or shears a translation. + let folded = enclosing_transform(&ops, start, end) + .map(|(at, cm)| (at, local.then(&cm))) + .or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local)))); + if let Some((at, folded)) = folded { + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + folds.insert(at, folded); + } else { + crop_starts.insert(start, local); + crop_ends.insert(end); + } + plans.remove(&image.op); + } for index in 0..ops.len() { let Some(&(_, placement, offset)) = plans.get(&index) else { continue }; let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; @@ -807,7 +1930,6 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize }; } // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. - let mut folds = std::collections::BTreeMap::new(); for index in 0..ops.len() { let Some(&(movement, _, _)) = plans.get(&index) else { continue }; let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; @@ -822,6 +1944,14 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("a selected image operation is missing".into())); } let data = crate::text::splice(&pieces, &ops, |index| { + if let Some(matrix) = crop_starts.remove(&index) { + return (vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())], true); + } + if crop_ends.remove(&index) { + let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect(); + out.push(Op::new("Q", vec![])); + return (out, false); + } if let Some((c, keep)) = clips.remove(&index) { return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); } @@ -841,3 +1971,44 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } + +#[cfg(test)] +mod allocation_tests { + use super::*; + + #[test] + fn saved_drawing_states_share_large_operand_storage() { + let mut tracker = DrawingStateTracker::default(); + let parsed = parse(b"[1 2 3 4 5 6 7 8] 0 d /Pattern cs 1 2 3 4 5 6 /P scn q q q"); + for op in &parsed.ops { + tracker.step(op).unwrap(); + } + for state in &tracker.stack { + let dash = state.params[4].as_ref().unwrap(); + let current_dash = tracker.current.params[4].as_ref().unwrap(); + assert!(std::ptr::eq(dash.operands.as_ptr(), current_dash.operands.as_ptr()), "q must share the dash operands instead of cloning them"); + let color = state.non_stroking_color.as_ref().unwrap().last().unwrap(); + let current_color = tracker.current.non_stroking_color.as_ref().unwrap().last().unwrap(); + assert!(std::ptr::eq(color.operands.as_ptr(), current_color.operands.as_ptr()), "q must share scn operands instead of cloning them"); + } + let saved = tracker.current.clone(); + for op in &parse(b"[9 10] 0 d 4 5 6 /Other scn Q").ops { + tracker.step(op).unwrap(); + } + assert_eq!(tracker.current, saved, "changing shared operands must preserve the saved state"); + } + + #[test] + fn review_soft_mask_identity_includes_establishment_matrix() { + let doc = Document::new_empty(); + let mask = Dict::from_iter([(b"S".to_vec(), Object::name("Alpha")), (b"G".to_vec(), Object::Ref(ObjRef::new(8, 0)))]); + let definition = Dict::from_iter([(b"SMask".to_vec(), Object::Dict(mask))]); + let states = Dict::from_iter([(b"A".to_vec(), Object::Dict(definition))]); + let mut tracker = ExtendedGStateTracker::default(); + let op = Op::new("gs", vec![Object::name("A")]); + tracker.step(&op, &states, &doc, Matrix::IDENTITY).unwrap(); + let before = tracker.current_state.clone(); + tracker.step(&op, &states, &doc, Matrix::translate(50.0, 0.0)).unwrap(); + assert!(before.visually_differs(&tracker.current_state), "the same mask established under a different CTM is a different effective mask"); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 193f7d8424..44a6730acc 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1,6 +1,11 @@ //! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer, //! Watermark, Background and Bates numbering (execution plan M7.6). //! +//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds +//! stages the whole call on a document snapshot and commits only after every selected page succeeds. +//! An error preserves existing marks, content, resources, pending edits and allocated objects. The +//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata. +//! //! **How marks are stored.** Each mark is one content stream appended to (or, for content behind //! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in //! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so @@ -35,6 +40,7 @@ pub type Rgb = [f64; 3]; pub mod backdrop; pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops}; +pub use text::strip_subset_tag; mod background; pub use background::page_background; pub mod objects; @@ -43,7 +49,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; +pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; pub mod bidi; pub mod indic; mod linebreak; diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs new file mode 100644 index 0000000000..2623da88ba --- /dev/null +++ b/crates/edit/src/mark_transaction_tests.rs @@ -0,0 +1,211 @@ +//! The public mark APIs either commit the complete operation or leave the document untouched. +use super::{cx, fixture, reopen, streams}; +use crate::place::MARK_PLACEMENT_FAILURE; +use crate::*; +use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental}; + +const SELECTED: &[usize] = &[0, 1, 2]; + +struct PlacementFailure; + +impl PlacementFailure { + fn after_two_pages(kind: MarkKind) -> Self { + MARK_PLACEMENT_FAILURE.with(|failure| { + assert!(failure.get().is_none()); + failure.set(Some((kind, 2, 0))); + }); + Self + } + + fn completed(&self) -> usize { + MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2) + } +} + +impl Drop for PlacementFailure { + fn drop(&mut self) { + MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None)); + } +} + +/// Four pages share an original stream and resources, both inherited and directly referenced. +/// All have all three existing marks, including a picture background with a replaced white fill. +fn marked_document() -> (Document, MarkSource) { + let mut doc = fixture(); + let parent = ObjRef::new(2, 0); + let extra = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(parent)), + (b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))), + ]))); + doc.update_dict(parent, |d| { + d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect())); + d.set(b"Count".to_vec(), Object::Int(4)); + }) + .unwrap(); + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec()))); + let picture = |doc: &mut Document, gray| { + let image = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::name("DeviceGray")), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ]), + vec![gray], + ))); + MarkSource { xobject: image, size: (1.0, 1.0), image: true } + }; + let old_picture = picture(&mut doc, 170); + let replacement_picture = picture(&mut doc, 80); + let all = [0, 1, 2, 3]; + let hf = HeaderFooter { + text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..Default::default() + }; + add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap(); + add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap(); + add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false) + .unwrap(); + let pages = page_list(&doc); + let shared = ObjRef::new(6, 0); + let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone(); + doc.set(shared, resources); + for (i, page) in pages.iter().enumerate() { + doc.update_dict(page.obj, |d| { + if i % 2 == 0 { + d.set(b"Resources".to_vec(), Object::Ref(shared)); + } else { + d.remove(b"Resources"); + } + }) + .unwrap(); + } + let doc = reopen(&doc); + assert!(!doc.is_modified()); + let pages = page_list(&doc); + assert_eq!(pages.len(), 4); + assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources"); + assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + for i in 0..4 { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}"); + } + } + (doc, replacement_picture) +} + +fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec { + streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect() +} + +fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> { + match kind { + MarkKind::HeaderFooter => add_header_footer( + doc, + SELECTED, + &HeaderFooter { + text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Courier, + shrink: true, + ..Default::default() + }, + replace, + &cx(), + ), + MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace), + MarkKind::Background => { + add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) + } + } +} + +fn unchanged_after_later_page_failure(kind: MarkKind) { + // Replacement and addition share the same operation boundary; exercise replacement first. + for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] { + let (mut doc, source) = marked_document(); + if pending_edit { + let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec()))); + doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap(); + } + let modified = doc.is_modified(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let objects = doc.object_numbers().len(); + let mut allocation_probe = doc.clone(); + let next_object = allocation_probe.add(Object::Null); + let failure = PlacementFailure::after_two_pages(kind); + let result = apply_replacement(&mut doc, kind, source, replace); + assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into()))); + assert_eq!(failure.completed(), 2, "the error followed two real page placements"); + assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain"); + assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved"); + assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged"); + assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too"); + } +} + +#[test] +fn header_footer_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::HeaderFooter); +} + +#[test] +fn watermark_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Watermark); +} + +#[test] +fn background_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Background); +} + +#[test] +fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + let (mut doc, source) = marked_document(); + let before = doc.clone(); + let expected = match kind { + MarkKind::HeaderFooter => "(New header)".to_string(), + MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), + MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + }; + apply_replacement(&mut doc, kind, source, true).unwrap(); + let doc = reopen(&doc); + assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content"); + let unselected = page_list(&before)[3].obj; + assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries"); + for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] { + assert_eq!( + format!("{:?}", doc.get(reference)), + format!("{:?}", before.get(reference)), + "shared resources and original content stay unchanged" + ); + } + for page in SELECTED { + let marks = mark_streams(&doc, *page, kind); + assert_eq!(marks.len(), 1, "one replacement on selected page {page}"); + assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]); + let old_page = &page_list(&before)[*page]; + let new_page = &page_list(&doc)[*page]; + let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap()); + let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap()); + for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] { + let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap()); + let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap()); + for (name, value) in old.as_dict().unwrap().iter() { + assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}"); + assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}"); + } + } + for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + if other != kind { + assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}"); + } + } + } + } +} diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..b535d60563 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -294,7 +294,32 @@ pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind).map(drop) + place_tagged(doc, page, kind.tag(), content, behind)?; + #[cfg(test)] + fail_after_mark_placement(kind)?; + Ok(()) +} + +#[cfg(test)] +thread_local! { + /// Force an error after N completed placements of one kind, on this test thread only. + pub(crate) static MARK_PLACEMENT_FAILURE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn fail_after_mark_placement(kind: MarkKind) -> Result<(), EditError> { + MARK_PLACEMENT_FAILURE.with(|failure| { + if let Some((target, after, completed)) = failure.get() + && target == kind + { + let completed = completed.saturating_add(1); + failure.set(Some((target, after, completed))); + if completed == after { + return Err(EditError::Invalid("injected mark placement failure".into())); + } + } + Ok(()) + }) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d45e983a2e..7ec51aa74f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4,6 +4,9 @@ use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; +#[path = "mark_transaction_tests.rs"] +mod mark_transactions; + /// Three pages: an upright page with content that leaves the graphics state changed (an /// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources. fn fixture() -> Document { @@ -3516,11 +3519,24 @@ fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } +/// Arrange, Crop and Remove crop as the engine asks for them, through [`images::change_image`]. +fn arrange(doc: &mut Document, page: usize, index: usize, order: images::ArrangeOrder) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Arrange(order)) +} + +fn crop(doc: &mut Document, page: usize, index: usize, keep: [f64; 4]) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Crop(keep)) +} + +fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::RemoveCrop) +} + fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), @@ -3540,6 +3556,57 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +fn stencil_page_with(content: &str) -> Document { + let objs: Vec = vec![ + "<< /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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0000>\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn form_page_with(content: &str) -> Document { + let form_content = "0 0 100 100 re f"; + let objs: Vec = vec![ + "<< /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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{form_content}\nendstream", form_content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + /// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used /// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, /// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). @@ -3728,6 +3795,880 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } +#[test] +fn image_arrange_stacking_order_and_remove_crop() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 2); + assert!(close(initial_images[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Boundary case: image 0 is already at the back; SendToBack and SendBackward report an error. + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 2); + assert!(close(imgs[0].rect, initial_images[0].rect)); + + // Bring forward image 0: moves it past the middle text, but it is still behind image 1. + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + // Middle text should now precede image 0's Do in the content stream! + let text_pos = str_content.find("Middle Text").unwrap(); + let first_do = str_content.find("/Im0 Do").unwrap(); + assert!(text_pos < first_do, "text should now precede image 0"); + + // Bring to front on image 0: moves it past image 1 to the front! + let mut doc = doc; + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let imgs_after_front = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs_after_front.len(), 2); + // In drawing order, the image that was at [100, 100, 200, 200] is now drawn LAST (index 1)! + assert!(close(imgs_after_front[0].rect, [200.0, 200.0, 350.0, 350.0])); + assert!(close(imgs_after_front[1].rect, [100.0, 100.0, 200.0, 200.0])); + + // Send to back on the front image (index 1) restores it to the back. + let mut doc = doc; + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let imgs_after_back = images::page_images(&doc, 0).unwrap(); + assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Test remove crop + let cropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(cropped_content); + crop(&mut doc, 0, 0, [100.0, 100.0, 150.0, 150.0]).unwrap(); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_ne!(img_before.visible_rect, Some(img_before.rect)); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_after = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_after.visible_rect, Some(img_after.rect)); + // Removing crop again when there is no crop is refused. + let mut doc = doc; + assert!(remove_crop(&mut doc, 0, 0).is_err()); + + // Test crop + let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(uncropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_before.visible_rect, Some(img_before.rect)); + crop(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0])); + // And remove_crop works on it: + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); + + // Test cropping an image that was moved (has an outer transform) + let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q"); + let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap(); + let doc = reopen(&doc); + let img_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]); + let mut doc = doc; + crop(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap(); + let doc = reopen(&doc); + let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted"); + assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0])); + // Adjust crop (re-crop) + let mut doc = doc; + crop(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_recropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0])); + // Remove crop from moved image + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); +} + +#[test] +fn crop_rotated_image_preserves_angle_and_in_place_geometry() { + let content = "q 173.20508 100 -50 86.60254 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + let m_before = img_before.matrix; + assert!((m_before[1] - 100.0).abs() < 1e-3); + assert!((m_before[2] - (-50.0)).abs() < 1e-3); + + crop(&mut doc, 0, 0, [140.0, 170.0, 180.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + + let m_after = img_cropped.matrix; + assert!((m_after[0] - m_before[0]).abs() < 1e-3); + assert!((m_after[1] - m_before[1]).abs() < 1e-3); + assert!((m_after[2] - m_before[2]).abs() < 1e-3); + assert!((m_after[3] - m_before[3]).abs() < 1e-3); + assert!((m_after[4] - m_before[4]).abs() < 1e-3); + assert!((m_after[5] - m_before[5]).abs() < 1e-3); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!( + (str_content.contains("re") || (str_content.contains("m\n") && str_content.contains("l\n"))) && str_content.contains("W\nn"), + "{str_content}" + ); + assert!(str_content.contains("/Im0 Do"), "{str_content}"); + + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.matrix, m_before); +} + +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +/// The marked-content sequence that `mcid` opens (`/MCID n >> BDC` … its `EMC`), as content text. +fn marked_sequence(content: &str, mcid: u32) -> &str { + let tag = format!("/MCID {mcid}"); + let start = content.find(&tag).unwrap_or_else(|| panic!("no {tag} in {content}")); + let end = content[start..].find("EMC").map(|e| start + e).unwrap_or_else(|| panic!("{tag} has no EMC in {content}")); + &content[start..end] +} + +/// #3145: Arrange treated marked-content boundaries as if they weren't there, so Bring to Front put +/// the image inside the text's `BDC`…`EMC` (tagged as the text) and left its own sequence empty. +#[test] +fn image_arrange_keeps_marked_content_sequences_whole() { + let content = "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(marked_sequence(&s, 0).contains("/Im0 Do"), "the image stays in its own sequence: {s}"); + assert!(!marked_sequence(&s, 1).contains("/Im0 Do"), "the text's sequence holds no image: {s}"); + assert!(marked_sequence(&s, 1).contains("(Text)"), "{s}"); + assert!(s.find("/MCID 1").unwrap() < s.find("/MCID 0").unwrap(), "the image's sequence now comes after the text's: {s}"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + +/// A `BMC` sequence (here an artifact) moves as a whole too. +#[test] +fn image_arrange_moves_a_bmc_sequence_as_a_whole() { + let content = "/Artifact BMC q 100 0 0 100 50 50 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let s = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let (art, emc, body, img) = (s.find("/Artifact BMC").unwrap(), s.rfind("EMC").unwrap(), s.find("(Body)").unwrap(), s.find("/Im0 Do").unwrap()); + assert!(body < art && art < img && img < emc, "text first, then the whole artifact sequence: {s}"); +} + +/// An image sharing a marked-content sequence with other content must not end up untagged. +/// Either the arrange call is refused with Err, or every image paint remains inside a marked-content sequence. +#[test] +fn arrange_image_in_shared_tagged_block_refuses_or_keeps_tagged() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + match res { + Err(_) => { + // Refusing is the expected safe behavior when moving out of a shared tagged unit. + } + Ok(()) => { + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let mut mc_depth = 0usize; + for op in ops { + if op.is("BDC") || op.is("BMC") { + mc_depth += 1; + } else if op.is("EMC") { + mc_depth = mc_depth.saturating_sub(1); + } else if op.is("Do") { + assert!(mc_depth > 0, "every image paint must be inside a marked-content sequence: {}", String::from_utf8_lossy(&bytes)); + } + } + } + } +} + +/// An image that shares its marked-content sequence with other content cannot be rearranged +/// without leaving the image untagged or duplicating MCIDs, so it is refused. +#[test] +fn image_arrange_splits_a_shared_marked_sequence_into_own_tag() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err()); +} + +/// Send backward also refuses to rearrange an image when it shares its sequence with other content. +#[test] +fn image_arrange_send_backward_splits_shared_marked_sequence() { + let content = + "BT /F1 12 Tf 72 700 Td (Header) Tj ET /Table <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Cell) Tj ET EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); +} + +#[test] +fn arrange_preserves_color_leaking_out_of_marked_content() { + let content = "/Figure <> BDC 1 0 0 rg q 100 0 0 100 0 0 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Text) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + if res.is_ok() { + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + let text_pos = s.find("(Text) Tj").unwrap(); + assert!(s[..text_pos].contains("1 0 0 rg") || s[..text_pos].contains("1 0 0 RG")); + } +} + +#[test] +fn arrange_shared_tagged_does_not_duplicate_mcid() { + let content = "/Figure <> BDC q 100 0 0 100 0 0 cm /Im0 Do Q 0 0 m 10 10 l S EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + crop(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + +#[test] +fn image_arrange_preserves_position_under_active_ctm() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Move first image to front: must not apply destination CTM twice! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: check order of operators in the content stream + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let first_do = str_content.find("/Im0 Do").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(first_do < last_do, "Position: multiple image Do calls exist and order was updated"); + + // 2. Appearance: both images' positions and sizes are preserved + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: second image position preserved"); + + // 3. Preservation: outer CTM is preserved + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: initial CTM preserved"); +} + +#[test] +fn image_arrange_preserves_shared_group_clipping_bounds() { + let content = "q 120 120 40 40 re W n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + assert_eq!(initial[1].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + + // Bring image 0 to front: moves it past the external text while preserving clipping bounds + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image Do now follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let moved_do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < moved_do_pos, "Position: moved image Do follows External Text"); + + // 2. Appearance: both images retain exact clipping bounds and geometry + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 1 retains clip"); + + // 3. Preservation: External Text and original group drawing preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); + assert!(str_content.contains("120 120 40 40 re W n"), "Preservation: clipping path operators preserved"); +} + +#[test] +fn image_arrange_preserves_extended_graphics_state_opacity() { + let content = "q /GS0 gs 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do moved past External Text + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: /GS0 gs is retained and directly precedes /Im0 Do + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + assert!(text_pos < gs_pos, "Appearance: /GS0 gs is in the new moved unit"); + assert!(gs_pos < do_pos, "Appearance: /GS0 gs directly precedes /Im0 Do"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: text preserved"); +} + +#[test] +fn image_arrange_preserves_exact_non_rectangular_clipping_path() { + let content = "q 120 120 m 160 120 l 140 160 l h W* n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < last_do, "Position: moved image follows External Text"); + + // 2. Appearance: polygon geometry and even-odd rule are preserved + assert!(str_content.contains("W*"), "Appearance: even-odd W* fill rule preserved"); + // The original group still clips the image left behind, so the moved copy must carry its own. + let flat = str_content.split_whitespace().collect::>().join(" "); + assert_eq!(flat.matches("120 120 m 160 120 l 140 160 l h W* n").count(), 2, "Appearance: exact polygon clip preserved in both units"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + for image in &after { + assert!(image.contains([140.0, 130.0]), "Appearance: inside the triangle shows"); + assert!(!image.contains([125.0, 155.0]), "Appearance: inside its box but outside the triangle stays clipped"); + } + + // 3. Preservation: External Text and remaining original group content preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); +} + +#[test] +fn image_remove_crop_after_arrange_keeps_position_under_outer_transform() { + // Arrange writes `cm inverse`, the clip, then the absolute placement; Remove Crop used to drop + // only the first, which drew the image under the outer translation twice. + let content = "1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [50.0, 50.0, 100.0, 100.0]).unwrap(); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + let moved = images::page_images(&doc, 0).unwrap(); + assert_eq!(moved[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(moved[0].visible_rect, Some([50.0, 50.0, 100.0, 100.0])); + + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "the image stays where it was"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0]), "the crop is gone"); +} + +#[test] +fn image_arrange_refuses_to_move_past_a_clip_it_is_not_under() { + // The text after the image is drawn under a top-level clip; moving the image after it would + // crop the image to that clip. + let content = "q 100 0 0 100 50 50 cm /Im0 Do Q 0 0 60 60 re W n BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + for order in [images::ArrangeOrder::BringToFront, images::ArrangeOrder::BringForward] { + let err = arrange(&mut doc, 0, 0, order).unwrap_err(); + assert!(err.to_string().contains("clipping path"), "{err}"); + } + assert_eq!(page_content_bytes(&doc, 0), before, "nothing changes"); +} + +#[test] +fn image_arrange_send_to_back_keeps_a_shared_clip() { + // The image after the text is clipped by a top-level clip set before both; behind the text it + // is no longer under that clip, so the moved unit has to draw it itself. + let content = "BT /F1 12 Tf 72 700 Td (External Text) Tj ET 0 0 60 60 re W n q 100 0 0 100 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.rfind("/Im0 Do").unwrap() < str_content.find("External Text").unwrap(), "the image moved behind the text"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 60.0, 60.0]), "still cropped to the clip"); +} + +#[test] +fn unwrapped_image_moves_successfully() { + let content = "100 0 0 100 50 50 cm /Im0 Do BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes Middle Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("Middle Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text"); + + // Move unwrapped image to front (past Middle Text) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows Middle Text in the content stream AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("Middle Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do actually moved after Middle Text"); + + // 2. Appearance: image rect and visibility are unchanged + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: image coordinates preserved"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + // 3. Preservation: Middle Text and CTM remain intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"), "Preservation: text operation preserved"); + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved"); +} + +#[test] +fn test_arrange_unwrapped_image_extraction() { + let content = b"100 0 0 100 100 100 cm /Im0 Do /Im1 Do"; + let ops = pdfcraft_content::parse(content).ops; + let target = ops.iter().position(|op| op.is("Do") && op.name(0) == Some(b"Im0")).expect("target /Im0 not found"); + + // Scope start for unwrapped image matches target because content_units isolates Do + let scope_start = target; + + // Accumulate effective CTM prior to the image operation + let mut initial_ctm = pdfcraft_content::Matrix::IDENTITY; + for op in &ops[..target] { + if op.is("cm") + && let Some(m) = op.nums::<6>() + { + initial_ctm = pdfcraft_content::Matrix(m).then(&initial_ctm); + } + } + assert_eq!(initial_ctm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0])); + + let extracted = images::extract_scope_state(&ops, scope_start, target, initial_ctm) + .expect("extraction must not return Err") + .expect("extraction must return Some rather than None"); + + assert_eq!(extracted.do_op.op.as_slice(), b"Do"); + assert_eq!(extracted.do_op.name(0), Some(b"Im0".as_slice())); +} + +#[test] +fn arrange_unwrapped_image_in_mixed_scope_succeeds() { + // Content unit contains text and an unwrapped image; scope extraction of unit fails on BT, + // so it safely falls back to extracting target Do directly. + let content = "q BT /F1 12 Tf 72 700 Td (Preceding Text) Tj ET 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 500 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "moved image follows External Text"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); +} + +#[test] +fn image_arrange_with_transformed_clipping_path_succeeds() { + // Nested graphics state: outer scope establishes a scaled clipping path (cm + path + W n), + // inner scope draws the image. + let content = "q 0.5 0 0 0.5 0 0 cm 100 100 m 300 100 l 300 300 l 100 300 l h W n q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + assert!(before[0].visible_rect.is_some()); + let rect_before = before[0].rect; + let clip_before = before[0].visible_rect; + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, rect_before); + assert_eq!(after[0].visible_rect, clip_before); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "image moved after text: {str_content}"); +} + +#[test] +fn inherited_source_opacity_is_preserved() { + // Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text + let content = "q /GS0 gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes External Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("External Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes External Text"); + + // Move image to front (past External Text, outside its original outer q...Q scope) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: effective appearance state (/GS0 gs) is injected and precedes /Im0 Do in the new unit + let after_gs = str_content.rfind("/GS0 gs").unwrap(); + assert!(after_text < after_gs, "Appearance: /GS0 gs injected into the new unit at destination"); + assert!(after_gs < after_do, "Appearance: /GS0 gs directly precedes /Im0 Do in the moved unit"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text and original outer scope structure are preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_opacity_pollution_is_prevented() { + // /Im0 Do is fully opaque at the start; destination has active top-level /GS_translucent gs + let content = "100 0 0 100 50 50 cm /Im0 Do /GS_translucent gs BT /F1 12 Tf 72 700 Td (Translucent Text) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: the document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +#[test] +fn sequential_gs_operators_merging_properties_are_preserved() { + // Sequential gs calls: /GSalpha sets ca=0.6, /GSblend sets BM=/Multiply (without touching ca). + let content = "q /GSalpha gs /GSblend gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: Both merged state operators (/GSalpha and /GSblend) are preserved and precede /Im0 Do + let after_alpha = str_content.rfind("/GSalpha gs").unwrap(); + let after_blend = str_content.rfind("/GSblend gs").unwrap(); + assert!(after_text < after_alpha, "Appearance: /GSalpha gs injected into moved unit"); + assert!(after_text < after_blend, "Appearance: /GSblend gs injected into moved unit"); + assert!(after_alpha < after_do, "Appearance: /GSalpha gs precedes /Im0 Do"); + assert!(after_blend < after_do, "Appearance: /GSblend gs precedes /Im0 Do"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_differing_blend_mode_pollution_is_prevented() { + // /Im0 Do has default Normal blend mode; destination has active top-level /GSblend gs (BM = /Multiply) + let content = "100 0 0 100 50 50 cm /Im0 Do /GSblend gs BT /F1 12 Tf 72 700 Td (Blended Section) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn nested_soft_mask_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /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 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R /GroupB 10 0 R >> /ExtGState << /GSA 7 0 R /GSB 8 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 10 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 50 100 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() { + let content = "q /GSA gs q /GSB gs 100 0 0 100 0 0 cm /Im0 Do Q Q BT /F1 12 Tf 10 90 Td (External Text) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert emitted stream order: outer /GSA gs must precede inner /GSB gs, which precedes /Im0 Do + let after_text = str_content.find("External Text").unwrap(); + let after_a = str_content.rfind("/GSA gs").unwrap(); + let after_b = str_content.rfind("/GSB gs").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_a, "Position: /GSA gs relocated past external text"); + assert!(after_a < after_b, "Order: outer /GSA gs must be replayed before inner /GSB gs"); + assert!(after_b < after_do, "Order: inner /GSB gs must immediately precede /Im0 Do"); + + // 2. The image survives saving and reopening. + assert_eq!(images::page_images(&reopen(&doc), 0).unwrap().len(), 1); + + // 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50) + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let px = |x: u32, y: u32| { + let idx = ((y * page.width + x) * 4) as usize; + page.rgba[idx..idx + 4].to_vec() + }; + // Left half (inside Mask B): image is rendered black + assert_eq!(px(25, 25), vec![0, 0, 0, 255], "point (25, 25) is inside Mask B: image rendered"); + assert_eq!(px(25, 75), vec![0, 0, 0, 255], "point (25, 75) is inside Mask B: image rendered"); + // Right half (outside Mask B): masked out to white page background + assert_eq!(px(75, 25), vec![255, 255, 255, 255], "point (75, 25) is outside Mask B: masked out to white"); + assert_eq!(px(75, 75), vec![255, 255, 255, 255], "point (75, 75) is outside Mask B: masked out to white"); +} + +#[test] +fn destination_differing_mask_pollution_is_prevented() { + // /Im0 Do has no mask; destination has active top-level /GSA gs (Mask A) + let content = "100 0 0 100 0 0 cm /Im0 Do /GSA gs BT /F1 12 Tf 10 90 Td (Masked Section) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial_bytes = page_content_bytes(&doc, 0); + + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than corrupting mask state"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn soft_mask_with_alpha_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /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 >> /XObject << /Im0 6 0 R /GroupA 9 0 R >> /ExtGState << /GSA 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /ca 0.25 /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask /None >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() { + let content = "q 2 0 0 2 10 10 cm /GSA gs 50 0 0 50 0 0 cm /Im0 Do Q BT /F1 12 Tf 10 180 Td (External Text) Tj ET"; + let mut doc = soft_mask_with_alpha_page(content); + + // Initial state: image bounds are [10, 10, 110, 110] + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 1); + assert!(close(initial_images[0].rect, [10.0, 10.0, 110.0, 110.0])); + + // Render baseline before reordering + let pdf_bytes_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_before = |x: u32, y: u32| { + let idx = ((y * page_before.width + x) * 4) as usize; + page_before.rgba[idx..idx + 4].to_vec() + }; + + // Arrange image: BringToFront + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert AST reordering succeeds and interleaves cm before gs + let text_pos = str_content.find("External Text").unwrap(); + let cm_mask_pos = str_content.rfind("2 0 0 2 10 10 cm").unwrap(); + let gs_pos = str_content.rfind("/GSA gs").unwrap(); + let cm_img_pos = str_content.rfind("50 0 0 50 0 0 cm").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + + assert!(text_pos < cm_mask_pos, "Image relocated after external text"); + assert!(cm_mask_pos < gs_pos, "CTM for soft mask established before /GSA gs"); + assert!(gs_pos < cm_img_pos, "/GSA gs executed before image cm"); + assert!(cm_img_pos < do_pos, "image cm executed before /Im0 Do"); + + // 2. Assert image bounds remain unchanged after saving and reopening + let reopened_doc = reopen(&doc); + let reopened_images = images::page_images(&reopened_doc, 0).unwrap(); + assert_eq!(reopened_images.len(), 1); + assert!(close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]), "Image bounds preserved: {:?}", reopened_images[0].rect); + + // 3. Render reopened document and assert non-overlapping pixels remain unchanged + let pdf_bytes_after = write_incremental(&reopened_doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_after = |x: u32, y: u32| { + let idx = ((y * page_after.width + x) * 4) as usize; + page_after.rgba[idx..idx + 4].to_vec() + }; + + // A point inside the masked image (e.g. at x=50, inside the image and bottom half of mask) + // Non-overlapping with text (text is at y=180). + // The pixel renders at [191, 191, 191, 255] (25% black over 100% white). + // In raster coordinates, raster y for PDF y=50 in a 200-high page is y=150. + let rendered_pixel_before = px_before(50, 150); + let rendered_pixel_after = px_after(50, 150); + assert_eq!(rendered_pixel_before, vec![191, 191, 191, 255], "baseline pixel rendered with 25% opacity mask"); + assert_eq!(rendered_pixel_after, rendered_pixel_before, "rendered non-overlapping pixels remain unchanged"); + + // Outside the image: pure white background + assert_eq!(px_after(5, 5), vec![255, 255, 255, 255]); + assert_eq!(px_after(5, 5), px_before(5, 5)); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents @@ -4179,6 +5120,56 @@ fn the_graphics_state_carries_over_between_content_streams() { assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } +fn two_stream_image_page(first: &str, second: &str) -> Document { + let objs: Vec = vec![ + "<< /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 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> /XObject << /Im0 7 0 R /Im1 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()), + format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\0\0\0\0\0\0\0\0\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn arrange_and_crop_image_in_second_content_stream_succeeds() { + let first = "q 100 0 0 100 50 50 cm /Im0 Do Q"; + let second = "q 100 0 0 100 200 200 cm /Im1 Do Q"; + let mut doc = two_stream_image_page(first, second); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 2); + assert_eq!(before[0].name, "Im0"); + assert_eq!(before[1].name, "Im1"); + + // Arrange second image (located in the second content stream) + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let after_arrange = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_arrange.len(), 2); + + // Crop image in second stream + crop(&mut doc, 0, 0, [210.0, 210.0, 290.0, 290.0]).unwrap(); + let after_crop = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_crop.len(), 2); + assert_eq!(after_crop[0].name, "Im1"); + assert!(close(after_crop[0].visible_rect.unwrap(), [210.0, 210.0, 290.0, 290.0])); + assert_eq!(after_crop[1].name, "Im0"); + assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect)); +} + /// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש, /// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`. fn hebrew_page(content: &str) -> Document { @@ -7495,6 +8486,155 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ); } +#[cfg(test)] +mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + + #[test] + fn prop_is_entire_page_wrapped_arbitrary_bytes(bytes in proptest::collection::vec(any::(), 0..2048)) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let _ = images::is_entire_page_wrapped(&ops); + } + + #[test] + fn prop_content_units_arbitrary_bytes( + bytes in proptest::collection::vec(any::(), 0..2048), + start in 0usize..50, + end in 0usize..50, + ) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let scan_start = start.min(ops.len()); + let scan_end = end.min(ops.len()).max(scan_start); + let units = images::content_units(&ops, scan_start, scan_end); + for u in units { + prop_assert!(u.start <= u.end); + prop_assert!(u.end <= ops.len()); + } + } + + #[test] + fn prop_arrange_image_arbitrary_malformed( + index in 0usize..10, + order_idx in 0u8..4, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let order = match order_idx { + 0 => images::ArrangeOrder::BringToFront, + 1 => images::ArrangeOrder::SendToBack, + 2 => images::ArrangeOrder::BringForward, + _ => images::ArrangeOrder::SendBackward, + }; + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = arrange(&mut doc, 0, index, order); + } + + #[test] + fn prop_crop_image_malformed_inputs( + x1 in -500.0f64..500.0, + y1 in -500.0f64..500.0, + x2 in -500.0f64..500.0, + y2 in -500.0f64..500.0, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = crop(&mut doc, 0, 0, [x1, y1, x2, y2]); + let _ = remove_crop(&mut doc, 0, 0); + } + } +} + +/// One 600×800 page: Im0 (4×2 gray image), Fm0 (200×100 Form), ExtGState GSone (ca 1). +fn regression_page(content: &str) -> Document { + let form = "0 g 0 0 200 100 re f"; + let objs: Vec = vec![ + "<< /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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /Fm0 7 0 R >> /ExtGState << /GSone << /Type /ExtGState /ca 1 >> >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Length {} >>\nstream\n{form}\nendstream", form.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn regression_rects(doc: &Document) -> Vec<[i64; 4]> { + let mut v: Vec<[i64; 4]> = images::page_images(doc, 0).unwrap().iter().map(|i| i.rect.map(|x| x.round() as i64)).collect(); + v.sort(); + v +} + +/// A `cm` inside a BDC…EMC unit applies to later content; arranging the unit must not shift anything. +#[test] +fn arrange_keeps_cm_leaking_out_of_marked_content() { + let mut doc = regression_page("/Figure <> BDC 1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q EMC q 10 0 0 10 200 200 cm /Im0 Do Q"); + let before = regression_rects(&doc); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(before, regression_rects(&reopen(&doc)), "other content moved"); +} + +/// Cropping a Form XObject to 100×100 keeps a 100×100 visible area (or is refused) — never 1×1. +#[test] +fn crop_form_xobject_keeps_requested_area() { + let mut doc = regression_page("q 1 0 0 1 100 100 cm /Fm0 Do Q"); + if crop(&mut doc, 0, 0, [100.0, 100.0, 200.0, 200.0]).is_err() { + return; // refusing to crop vector art is also acceptable + } + let after = images::page_images(&reopen(&doc), 0).unwrap()[0].clone(); + let v = after.visible_rect.unwrap_or(after.rect); + assert!((v[2] - v[0] - 100.0).abs() < 1.0 && (v[3] - v[1] - 100.0).abs() < 1.0, "expected 100x100 visible, got {v:?}"); +} + +/// A gs naming a missing ExtGState must not be read as 50% opacity and block the reorder. +#[test] +fn arrange_tolerates_missing_extgstate() { + let mut doc = regression_page("q /GSmissing gs 100 0 0 100 50 50 cm /Im0 Do Q /GSone gs q 10 0 0 10 200 200 cm /Im0 Do Q"); + let r = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(r.is_ok(), "{r:?}"); +} + +/// Bring to front on the image already in front must not report a change it didn't make. +#[test] +fn noop_arrange_is_not_ok_without_change() { + let mut doc = regression_page("q 10 0 0 10 200 200 cm /Im0 Do Q q 100 0 0 100 50 50 cm /Im0 Do Q"); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let r = arrange(&mut doc, 0, 1, images::ArrangeOrder::BringToFront); + let after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(r.is_err() || before != after, "Ok with no change"); +} + +/// Cropping a turned image keeps the requested upright box in user space, rather than expanding +/// to the bounding box of the turned image coordinates. +#[test] +fn crop_turned_image_preserves_requested_area() { + let mut doc = regression_page("q 70.71 70.71 -70.71 70.71 300 100 cm /Im0 Do Q"); + let req = [270.0, 150.0, 330.0, 200.0]; + crop(&mut doc, 0, 0, req).expect("crop succeeds"); + let after = &images::page_images(&reopen(&doc), 0).unwrap()[0]; + let v = after.visible_rect.expect("visible rect exists"); + assert!((v[0] - req[0]).abs() < 1.0, "x0: expected {}, got {}", req[0], v[0]); + assert!((v[1] - req[1]).abs() < 1.0, "y0: expected {}, got {}", req[1], v[1]); + assert!((v[2] - req[2]).abs() < 1.0, "x1: expected {}, got {}", req[2], v[2]); + assert!((v[3] - req[3]).abs() < 1.0, "y1: expected {}, got {}", req[3], v[3]); +} + /// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given /// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when /// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the @@ -7758,6 +8898,122 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } +#[test] +fn arrange_preserves_clip_leaked_out_of_marked_unit() { + let content = "/Figure <> BDC 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert!(initial[0].visible_rect.is_some()); + assert!(initial[1].visible_rect.is_none(), "second image is fully clipped initially"); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + let img1 = after.iter().find(|img| (img.rect[0] - 400.0).abs() < 1e-3).expect("second image exists"); + assert!(img1.visible_rect.is_none(), "second image must stay clipped after BringToFront"); + + // Conflicting leaking transform with leaking clip must be refused + let content_cm_clip = + "/Figure <> BDC 2 0 0 2 0 0 cm 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc3 = image_page_with(content_cm_clip); + let res3 = arrange(&mut doc3, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res3.is_err(), "moving marked unit with both leaking cm and leaking clip must be refused"); +} + +#[test] +fn arrange_preserves_leaked_color_and_text_state() { + let content = "/Figure <> BDC /DeviceRGB cs 1 0 0 sc /F1 14 Tf 2 Tc q 10 0 0 10 50 50 cm /Im0 Do Q EMC 0 0 10 10 re f q 10 0 0 10 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 sc"), "sc must be preserved in content stream: {s}"); + assert!(s.contains("/F1 14 Tf"), "Tf must be preserved in content stream: {s}"); + assert!(s.contains("2 Tc"), "Tc must be preserved in content stream: {s}"); + + // Pixel test: 0 0 10 10 re f still fills red [255, 0, 0] after arrange + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let idx = (((page.height - 5) * page.width + 5) * 4) as usize; + assert_eq!(page.rgba[idx..idx + 3], [255, 0, 0], "point (5, 5 from bottom) inside 10x10 rect fills red"); +} + +#[test] +fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() { + // 1. Stencil mask test: + // Page has: q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + // Non-overlapping stencil and text. + let content = "q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut doc = stencil_page_with(content); + + // Render before move: + let pdf_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let stencil_pixel_idx = (((page_before.height - 75) * page_before.width + 75) * 4) as usize; + assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red"); + + // Bring to front + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + // Save and reopen + let doc = reopen(&doc); + + // Verify content stream has inherited color + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 rg"), "inherited 1 0 0 rg must be replayed in moved unit: {s}"); + + // Render after reopen and verify stencil remains red: + let pdf_after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(page_after.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil stays red after BringToFront and reopen"); + + // 2. Form XObject test: + // Page has: q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + let form_content = "q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut form_doc = form_page_with(form_content); + + let form_pdf_before = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_before), pdfcraft_render::RenderConfig::default()); + let form_page_before = form_renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize; + assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green"); + + arrange(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let form_doc = reopen(&form_doc); + + let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_after), pdfcraft_render::RenderConfig::default()); + let form_page_after = form_renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(form_page_after.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form stays green after BringToFront and reopen"); +} /// Existing-row alignment is explicitly refused without mutating the paragraph. #[test] fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() { @@ -8500,6 +9756,120 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } +#[test] +fn image_exclusive_scope_resets_path_on_scope_exit() { + // An earlier scope has a path operation closed by Q (with 'h' closepath). + // The subsequent image in its own scope must cleanly extract and allow crop removal. + let content = "q 0 0 m 10 10 l h Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [100.0, 100.0, 250.0, 180.0]).unwrap(); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + remove_crop(&mut doc, 0, 0).unwrap(); +} + +#[test] +fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() { + // /GS0 gs followed by Q and subsequent images must isolate each Do unit properly + let content = "q /GS0 gs Q q 200 0 0 100 100 100 cm /Im0 Do Q q 10 0 0 10 300 300 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[content.as_bytes()]); + let ops = pieces.parse().ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units.len(), 2); +} + +#[test] +fn different_inline_soft_masks_have_distinct_identities() { + let doc = Document::new_empty(); + let mut dict1 = Dict::default(); + let mut mask1 = Dict::default(); + mask1.set(b"Type".to_vec(), Object::Name(b"Mask1".to_vec())); + dict1.set(b"SMask".to_vec(), Object::Dict(mask1)); + + let mut dict2 = Dict::default(); + let mut mask2 = Dict::default(); + mask2.set(b"Type".to_vec(), Object::Name(b"Mask2".to_vec())); + dict2.set(b"SMask".to_vec(), Object::Dict(mask2)); + + let mut state1 = images::EffectiveGState::default(); + state1.merge_dict(&dict1, &doc, pdfcraft_content::Matrix::IDENTITY); + + let mut state2 = images::EffectiveGState::default(); + state2.merge_dict(&dict2, &doc, pdfcraft_content::Matrix::IDENTITY); + + assert!(state1.visually_differs(&state2), "two different inline soft masks must visually differ"); +} + +#[test] +fn is_entire_page_wrapped_with_nested_scopes() { + let wrapped = "q 1 0 0 1 0 0 cm q 0 0 10 10 re f Q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[wrapped.as_bytes()]); + let ops = pieces.parse().ops; + assert!(images::is_entire_page_wrapped(&ops), "page wrapped in outer q...Q with inner nested q...Q is wrapped"); + + let unwrapped = "q 1 0 0 1 0 0 cm Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces2 = pdfcraft_content::Pieces::join(&[unwrapped.as_bytes()]); + let ops2 = pieces2.parse().ops; + assert!(!images::is_entire_page_wrapped(&ops2), "page with two separate top-level q...Q scopes is not wrapped"); +} + +#[test] +fn hostile_page_with_many_gs_and_nested_q_does_not_hang_or_oom() { + use std::fmt::Write as _; + let mut stream = String::new(); + for i in 0..10_000 { + let _ = write!(stream, "/G{i} gs "); + } + for _ in 0..500 { + stream.push_str("q "); + } + stream.push_str("100 0 0 100 0 0 cm /Im0 Do "); + for _ in 0..500 { + stream.push_str("Q "); + } + let doc = image_page_with(&stream); + let start = std::time::Instant::now(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 1); + assert!(start.elapsed().as_secs() < 5, "page_images on hostile 10k gs must finish in under 5s, took {:?}", start.elapsed()); +} + +#[test] +fn crop_image_with_leading_cm_in_scope_places_clip_before_internal_cm() { + let content = "q 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [50.0, 50.0, 55.0, 55.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert!(after[0].visible_rect.is_some()); + let (v0, v1, v2, v3) = + (after[0].visible_rect.unwrap()[0], after[0].visible_rect.unwrap()[1], after[0].visible_rect.unwrap()[2], after[0].visible_rect.unwrap()[3]); + assert!((v0 - 50.0).abs() < 1e-3 && (v2 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); + assert!((v1 - 50.0).abs() < 1e-3 && (v3 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); +} + +#[test] +fn remove_crop_preserves_cm_before_extgstate() { + let content = "q 2 0 0 2 0 0 cm /GS0 gs 0 0 100 100 re W n 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [100.0, 100.0, 110.0, 110.0]).unwrap(); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("W n"), "the author clipping path must remain: {s}"); + assert!(!s.contains("PdfCraftImageCrop"), "the editor crop must be removed: {s}"); + // Verify that the initial cm appears before /GS0 gs + let cm_pos = s.find("2 0 0 2 0 0 cm").expect("cm must be preserved"); + let gs_pos = s.find("/GS0 gs").expect("gs must be preserved"); + assert!(cm_pos < gs_pos, "cm must precede /GS0 gs so soft mask space is preserved: {s}"); +} + /// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 /// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the /// text after `Q` is found, and stays, where it is drawn. @@ -8576,3 +9946,363 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() assert_eq!(page_content_bytes(&doc, 0), before); assert_eq!(page_content_bytes(&reopen(&doc), 0), before); } + +#[test] +fn image_arrange_refuses_crossed_scope_boundaries_without_changes() { + for content in ["/P <> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", "q /P <> BDC /Im0 Do Q EMC /Im0 Do"] { + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err(), "{content}"); + assert_eq!(page_content_bytes(&doc, 0), before); + } +} + +#[test] +fn removing_a_crop_preserves_author_clips_and_works_under_outer_clip() { + for content in ["0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q", "q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"] { + let mut doc = image_page_with(content); + let original = images::page_images(&doc, 0).unwrap()[0].clone(); + crop(&mut doc, 0, 0, [52.0, 52.0, 55.0, 55.0]).unwrap(); + let mut doc = reopen(&doc); + remove_crop(&mut doc, 0, 0).unwrap(); + let mut doc = reopen(&doc); + let restored = images::page_images(&doc, 0).unwrap()[0].clone(); + assert_eq!(restored.rect, original.rect); + assert_eq!(restored.visible_rect, original.visible_rect); + assert_eq!(restored.contains([59.0, 59.0]), original.contains([59.0, 59.0])); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + } +} + +#[test] +fn remove_crop_refuses_an_unmarked_author_clip_without_changes() { + let mut doc = image_page_with("q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"); + let before = page_content_bytes(&doc, 0); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); +} + +#[test] +fn arrange_refuses_unrestorable_extended_print_state() { + let mut unexpectedly_moved = Vec::new(); + for (key, value) in [ + ("TR", Object::name("Identity")), + ("TR2", Object::name("Default")), + ("OP", Object::Bool(true)), + ("op", Object::Bool(true)), + ("OPM", Object::Int(1)), + ("BG", Object::name("Default")), + ("BG2", Object::name("Default")), + ("UCR", Object::name("Default")), + ("UCR2", Object::name("Default")), + ("HT", Object::name("Default")), + ] { + let mut doc = image_page_with("q 100 0 0 100 50 50 cm /Im0 Do Q /Print gs BT /F1 12 Tf 20 700 Td (Text) Tj ET"); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(key.as_bytes().to_vec(), value)])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = page_content_bytes(&doc, 0); + match arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront) { + Ok(()) => unexpectedly_moved.push(key), + Err(error) => { + assert!(error.to_string().contains("graphics state"), "{key}: {error}"); + assert_eq!(page_content_bytes(&doc, 0), before, "{key} refusal changes nothing"); + } + } + } + assert!(unexpectedly_moved.is_empty(), "unrestorable destination state was accepted: {unexpectedly_moved:?}"); +} + +#[test] +fn arrange_keeps_implicit_device_spaces_before_sc_and_scn() { + for (select, color) in [("0 g", "0.5 sc"), ("0 0 0 rg", "1 0 0 sc"), ("0 0 0 1 k", "0 1 1 0 scn")] { + for stroke in [false, true] { + let (select, color) = if stroke { (select.to_uppercase(), color.to_uppercase()) } else { (select.into(), color.into()) }; + for content in [ + format!("q {select} {color} q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + format!("q {select} q {color} 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + ] { + let mut doc = stencil_page_with(&content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let do_index = ops.iter().rposition(|op| op.is("Do")).unwrap(); + let start = ops[..do_index].iter().rposition(|op| op.is("q")).unwrap(); + let relocated = &ops[start..do_index]; + let selector = select.split_whitespace().last().unwrap(); + let color_op = color.split_whitespace().last().unwrap(); + let space_index = relocated.iter().position(|op| op.is(selector)); + let color_index = relocated.iter().position(|op| op.is(color_op)); + assert!( + space_index.is_some() && space_index < color_index, + "{select} must select its device space before {color}: {}", + String::from_utf8_lossy(&bytes) + ); + } + } + } +} + +#[test] +fn arrange_restores_initial_rendering_intent_from_ri_and_gs() { + for selection in ["/Saturation ri", "/Print gs"] { + let mut doc = image_page_with(&format!("q 100 0 0 100 50 50 cm /Im0 Do Q {selection} BT /F1 12 Tf 20 700 Td (Text) Tj ET")); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(b"RI".to_vec(), Object::name("Saturation"))])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + assert!( + String::from_utf8_lossy(&bytes).contains("/RelativeColorimetric ri"), + "default rendering intent must be restored after {selection}: {}", + String::from_utf8_lossy(&bytes) + ); + } +} + +#[test] +fn editor_crop_moves_with_repeated_group_translation() { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + crop(&mut doc, 0, 0, [150.0, 120.0, 250.0, 170.0]).unwrap(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut count = 0; + for iteration in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + if iteration == 0 { + count = ops.len(); + } + assert_eq!(ops.len(), count, "repeated moves fold the same transform"); + } + let shifted = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let doc = reopen(&doc); + let after = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(after.rect, shifted(start.rect))); + assert!( + close(after.visible_rect.unwrap(), shifted(start.visible_rect.unwrap())), + "the editor crop moves with its pixels: {:?}", + after.visible_rect + ); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].rect, caption_rect); +} + +#[test] +fn arranging_original_image_keeps_it_independent_of_added_items() { + for update in [false, true] { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + add_content( + &mut doc, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Added caption".into(), ..AddedText::default() }), + ) + .unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(list_added(&doc).len(), 1, "the added item stays editable"); + if update { + update_content( + &mut doc, + 0, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Updated caption".into(), ..AddedText::default() }), + ) + .unwrap(); + } else { + delete_content(&mut doc, 0, 0).unwrap(); + } + let doc = reopen(&doc); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "editing/deleting an added item must preserve arranged original artwork"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [100.0, 100.0, 300.0, 200.0]); + let text = text::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect::>(); + assert!(text.iter().any(|line| line == "Caption"), "the original caption survives"); + assert_eq!(text.iter().any(|line| line == "Updated caption"), update); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().find(|line| line.text == "Caption").unwrap().rect, caption_rect); + } +} + +#[test] +fn arranging_nested_marked_image_refuses_without_losing_tags() { + for marked in ["/Figure <> BDC", "/Figure BMC"] { + let mut doc = image_page_with(&format!( + "q {marked} 100 0 0 100 50 50 cm /Im0 Do EMC BT /F1 12 Tf 20 700 Td (Text) Tj ET Q BT /F1 12 Tf 20 720 Td (Outside) Tj ET" + )); + let before = page_content_bytes(&doc, 0); + let error = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap_err(); + assert!(error.to_string().contains("marked-content"), "{error}"); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +#[test] +fn editor_crop_group_move_keeps_an_outer_author_clip_fixed() { + let mut doc = image_page_with("0 0 220 300 re W n q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 10 250 Td (Caption) Tj ET"); + crop(&mut doc, 0, 0, [150.0, 120.0, 210.0, 170.0]).unwrap(); + let mut operators = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let count = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.len(); + if i == 0 { + operators = count; + } + assert_eq!(count, operators, "moves under an inherited clip must fold their transform"); + } + let doc = reopen(&doc); + let image = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(image.rect, [120.0, 60.0, 320.0, 160.0])); + assert!( + close(image.visible_rect.unwrap(), [170.0, 80.0, 220.0, 130.0]), + "the editor crop moves but its author's outer clip stays fixed: {:?}", + image.visible_rect + ); + let bytes = page_content_bytes(&doc, 0); + assert!(String::from_utf8_lossy(&bytes).starts_with("0 0 220 300 re W n")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); +} + +fn review_state_raster(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }).rgba.to_vec() +} + +#[test] +fn review_arrange_form_keeps_inherited_text_spacing() { + for (spacing, later_spacing) in [(0, false), (12, false), (0, true)] { + let initial = if later_spacing { String::new() } else { format!("BT {spacing} Tc ET") }; + let later = if later_spacing { "BT 12 Tc ET" } else { "" }; + let content = format!("q {initial} q 1 0 0 1 20 20 cm /Fm0 Do Q Q {later} q 0 0 1 rg 200 200 5 5 re f Q"); + let mut doc = form_page_with(&content); + let form = pdfcraft_cos::ObjRef::new(6, 0); + let dict = doc.get(form).as_dict().unwrap().clone(); + doc.set(form, Object::Stream(pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 20 Tf 0 15 Td (ABC) Tj ET".to_vec()))); + let doc_before = reopen(&doc); + let before = review_state_raster(&doc_before); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == 0 && p[1] == 0 && p[2] == 0), "the Form actually paints text"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let saved = reopen(&doc); + assert_eq!(images::page_images(&saved, 0).unwrap()[0].rect, images::page_images(&doc_before, 0).unwrap()[0].rect); + assert_eq!(review_state_raster(&saved), before, "inherited {spacing} Tc must survive arranging the Form and reopening"); + } +} + +fn review_shared_mask_page(states: &str, luminosity_b: bool, mask_paint: &str) -> Document { + let content = format!("q {states} q 100 0 0 100 20 20 cm /Im0 Do Q Q q 0 0 1 rg 200 200 5 5 re f Q"); + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let subtype = if luminosity_b { "Luminosity" } else { "Alpha" }; + let mask_b = format!("<< /Type /ExtGState /SMask << /S /{subtype} /G 8 0 R >> >>"); + let group = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /I true /CS /DeviceGray >> /Length {} >>\nstream\n{mask_paint}\nendstream", + mask_paint.len() + ); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> /ExtGState << /A 6 0 R /B 7 0 R >> >> >>", + &stream, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\0\0\0\nendstream", + "<< /Type /ExtGState /SMask << /S /Alpha /G 8 0 R >> >>", + &mask_b, + &group, + ]) +} + +#[test] +fn review_repeated_gs_keeps_alpha_vs_luminosity_mask_chronology() { + for states in ["/A gs", "/A gs /B gs", "/A gs /B gs /A gs"] { + let mut doc = review_shared_mask_page(states, true, "0.25 g 0 0 300 300 re f"); + let before = review_state_raster(&reopen(&doc)); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == p[1] && p[1] == p[2] && p[0] < 230), "the masked image is visible"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask application order must survive: {states}"); + } +} + +#[test] +fn review_repeated_gs_keeps_last_mask_establishment_ctm() { + for states in [ + "1 0 0 1 50 0 cm /A gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm /A gs", + ] { + let mut doc = review_shared_mask_page(states, false, "0.25 g 0 0 60 300 re f"); + let before = review_state_raster(&reopen(&doc)); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask CTM must survive: {states}"); + } +} + +#[test] +fn review_soft_masks_sharing_a_group_keep_distinct_configurations() { + let doc = Document::new_empty(); + let mask = |subtype: &str| { + Dict::from_iter([( + b"SMask".to_vec(), + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name(subtype)), (b"G".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))])), + )]) + }; + let mut alpha = images::EffectiveGState::default(); + let mut luminosity = images::EffectiveGState::default(); + alpha.merge_dict(&mask("Alpha"), &doc, pdfcraft_content::Matrix::IDENTITY); + luminosity.merge_dict(&mask("Luminosity"), &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(alpha.visually_differs(&luminosity), "a shared group does not make Alpha and Luminosity the same mask"); + for (key, value) in [ + (b"BC".to_vec(), Object::Array(vec![Object::Real(0.5)])), + ( + b"TR".to_vec(), + Object::Dict(Dict::from_iter([ + (b"FunctionType".to_vec(), Object::Int(2)), + (b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])), + (b"N".to_vec(), Object::Int(2)), + ])), + ), + ] { + let mut changed = mask("Luminosity"); + let Object::Dict(definition) = changed.get_mut(b"SMask").unwrap() else { panic!("mask dictionary") }; + definition.set(key, value); + let mut state = images::EffectiveGState::default(); + state.merge_dict(&changed, &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(state.visually_differs(&luminosity), "mask backdrop and transfer function belong to its definition"); + } +} + +#[test] +fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { + for scale in [1, 2] { + let content = format!("{scale} 0 0 {scale} 0 0 cm q 40 0 0 40 10 10 cm /Im0 Do Q q 0 0 1 rg 75 75 5 5 re f Q"); + let mut cropped = image_page_with(&content); + let keep = if scale == 2 { [30.0, 30.0, 70.0, 70.0] } else { [15.0, 15.0, 35.0, 35.0] }; + crop(&mut cropped, 0, 0, keep).unwrap(); + arrange(&mut cropped, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + for offset in [[10.0, 0.0], [0.0, 10.0], [-10.0, 3.0]] { + let mut grouped = reopen(&cropped); + let mut direct = reopen(&cropped); + let before = images::page_images(&grouped, 0).unwrap()[0].clone(); + images::translate_images(&mut grouped, 0, &[0], offset).unwrap(); + images::change_image(&mut direct, 0, 0, &images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]])).unwrap(); + let moved = |r: [f64; 4]| [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + let saved = reopen(&grouped); + let image = &images::page_images(&saved, 0).unwrap()[0]; + assert!(close(image.rect, moved(before.rect)), "scale {scale} offset {offset:?}: {:?}", image.rect); + assert!(close(image.visible_rect.unwrap(), moved(before.visible_rect.unwrap())), "the crop follows the complete displacement"); + assert_eq!( + review_state_raster(&saved), + review_state_raster(&reopen(&direct)), + "group and direct transforms must agree, preserving other artwork" + ); + } + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 98824c7694..0599b8ed38 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -24,6 +24,7 @@ use crate::EditError; mod regions; #[path = "text_session.rs"] mod session; +pub use fallback::strip_subset_tag; use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; pub use session::TextSession; @@ -47,7 +48,7 @@ pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_l pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, - source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs, + source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs, }; #[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index b8f45a221f..78f9014a20 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -163,7 +163,7 @@ pub(crate) fn same_base_font(a: &str, b: &str) -> bool { } /// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged. -pub(crate) fn strip_subset_tag(name: &str) -> &str { +pub fn strip_subset_tag(name: &str) -> &str { let bytes = name.as_bytes(); let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase)); if is_tag { name.get(7..).unwrap_or(name) } else { name } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6dae6adf0a..f5b924c921 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,6 +19,7 @@ pdfcraft-annot = { workspace = true } pdfcraft-forms = { workspace = true } pdfcraft-xfa = { workspace = true } pdfcraft-edit = { workspace = true } +pdfcraft-content = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-redact = { workspace = true } pdfcraft-print = { workspace = true } @@ -58,3 +59,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] pdfcraft-testkit = { workspace = true } +# JBIG2 fixtures (`encode_jbig2_generic`). +pdfcraft-filters = { workspace = true } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..0647726452 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ + c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"), + c("view.hand_tool", "Hand", None, None, Document, "hand"), c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. @@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.underline", "Underline text", None, None, Annotate, "underline"), c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"), + c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"), c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), c("comment.line", "Draw a line", None, None, Annotate, "minus"), c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..c8262a7241 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str { /// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..53b890f27c 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -18,9 +18,8 @@ impl Document { /// [`Document::page_images`]: a raster image, or Form artwork that holds one. pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option { let editor = self.editor.as_ref()?; - let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]); self.page_images(page).iter().rposition(|i| { - inside(&i.rect) + i.contains(at) && match (i.is_form, i.object) { (false, Some(_)) => true, (true, Some(form)) => picture_in_form(&editor.cos, form).is_some(), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d28429de6f..91b33ad017 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; +use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -20,6 +20,12 @@ pub enum ImageEdit { bytes: Arc>, }, Delete, + /// Affine transform [a, b, c, d, e, f] applied to the image's matrix. + Transform([f64; 6]), + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, } /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index c3339453af..5343804ef3 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -111,10 +111,17 @@ pub const EDIT_LABELS: &[&str] = &[ "Move object", "Move objects", "Move image", + "Transform image", "Rotate image", + "Crop image", "Flip image", "Replace image", "Delete image", + "Bring to front", + "Bring forward", + "Send backward", + "Send to back", + "Remove crop", "Add header & footer", "Update header & footer", "Add watermark", @@ -254,11 +261,19 @@ impl Edit { Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", + ImageEdit::Transform(_) => "Transform image", ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", ImageEdit::Crop(_) => "Crop image", ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", + ImageEdit::Arrange(ord) => match ord { + pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front", + pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward", + pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward", + pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back", + }, + ImageEdit::RemoveCrop => "Remove crop", }), Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), @@ -312,6 +327,7 @@ fn add_annotation(s: &Shape) -> &'static str { Shape::Oval { .. } => "Add oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", Shape::Line { .. } => "Add line", + Shape::FreehandHighlight { .. } => "Add freehand highlight", Shape::Ink { .. } => "Add drawing", Shape::TextBox { .. } => "Add text box", Shape::Typewriter { .. } => "Add text", diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..5ed70aed7a 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; +use pdfcraft_content::Matrix; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { @@ -250,6 +251,169 @@ impl EditCtx { } } +fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> { + match change { + ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into()))); + } + ImageEdit::Transform(t) => { + let [a, b, c, d, _, _] = *t; + let determinant = a * d - b * c; + if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into()))); + } + } + ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into()))); + } + _ => {} + } + let img = pdfcraft_edit::page_images(doc, page)? + .into_iter() + .nth(index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into()))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order), + ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, + }; + let page_model = pdfcraft_model::pages(doc) + .into_iter() + .nth(page) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?; + let target_stream_obj = match page_model.dict.get(b"Contents") { + None => None, + Some(c) => match &*doc.resolve(c) { + pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()), + _ if img.stream == 0 => c.as_ref(), + _ => None, + }, + }; + + let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect(); + // Ownership belongs to the drawing stream, not the XObject: original and added images may + // show the same resource without sharing an editable item. + let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t)); + let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a))); + + let mut ai_to_update = None; + if let Some((added_idx, added_item)) = matched_added + && let AddedContent::Image(ai) = added_item.content.clone() + { + let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) + || img.visible_rect.is_some_and(|v| { + (v[0] - img.rect[0]).abs() > 1e-4 + || (v[1] - img.rect[1]).abs() > 1e-4 + || (v[2] - img.rect[2]).abs() > 1e-4 + || (v[3] - img.rect[3]).abs() > 1e-4 + }); + let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) + if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); + let should_promote = has_active_crop + || non_axis_transform + || matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete); + + if should_promote { + doc.update_dict(added_item.obj, |d| { + d.remove(b"PCAdded"); + d.remove(b"PCMark"); + }) + .map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?; + } else { + ai_to_update = Some((*added_idx, ai)); + } + } + + if let Some((added_idx, mut ai)) = ai_to_update { + let view = Matrix(page_model.view_matrix(doc)); + let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?; + + match change { + ImageEdit::Move(to) => { + ai.rect = to_display.bbox(*to); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Transform(_) | ImageEdit::Flip { .. } => { + let pdfcraft_edit::ImageChange::Transform(t) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into()))); + }; + let t_display = view.then(&Matrix(t)).then(&to_display); + let [ta, tb, tc, td, _, _] = t_display.0; + let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5; + let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5; + + let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel { + (0, ta < 0.0, td < 0.0) + } else if is_perpendicular { + match (tb > 0.0, tc > 0.0) { + (true, false) => (1, false, false), + (false, true) => (3, false, false), + (true, true) => (1, false, true), + (false, false) => (1, true, false), + } + } else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "non-axis-aligned transforms are not supported for added images".into(), + ))); + }; + let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) }; + ai.flip_h ^= toggle_h; + ai.flip_v ^= toggle_v; + ai.rotation = (ai.rotation + quarters_ccw) % 4; + ai.rect = t_display.bbox(ai.rect); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Rotate(q) => { + let k = ((4 - q.rem_euclid(4)) % 4) as u8; + ai.rotation = (ai.rotation + k) % 4; + let r = ai.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if k % 2 == 1 { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; + } + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Replace { .. } => { + let pdfcraft_edit::ImageChange::Replace(new_image) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into()))); + }; + ai.image = new_image; + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + } + } else { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + Ok(()) +} + /// Perform an edit on a working copy (the caller discards it on error). pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { match edit { @@ -525,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } + Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?, Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b49cfd4e1..fe139164be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -54,6 +54,7 @@ mod stamp_preview; mod test_hooks; pub mod text_fonts; mod text_preview; +pub mod text_script; pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; @@ -64,6 +65,7 @@ use std::sync::Arc; pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; +pub use pdfcraft_edit::strip_subset_tag; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::{CommentLayer, StampPreview}; @@ -137,7 +139,7 @@ pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; -pub use pictures::MarkFile; +pub use pictures::{MarkFile, image_dimensions}; use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; @@ -154,6 +156,8 @@ use xfa_form::{ mod clock; mod doc_state; +pub use pdfcraft_edit::ArrangeOrder; + use clock::{local_utc_offset, parse_ymd}; pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files}; use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable}; diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index b29941720a..49d76f07c0 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use image::ImageDecoder; + use crate::EditError; /// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` @@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) } } + +/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster. +pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let format = image::guess_format(bytes).ok()?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(64_000_000 * 4); + reader.limits(limits); + let mut decoder = reader.into_decoder().ok()?; + let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder); + if w == 0 || h == 0 { + return None; + } + if format == image::ImageFormat::Jpeg + && u64::from(w) * u64::from(h) <= (64 << 20) + && let Ok(orientation) = decoder.orientation() + && matches!( + orientation, + image::metadata::Orientation::Rotate90 + | image::metadata::Orientation::Rotate270 + | image::metadata::Orientation::Rotate90FlipH + | image::metadata::Orientation::Rotate270FlipH + ) + { + std::mem::swap(&mut w, &mut h); + } + Some((w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec { + use image::codecs::jpeg::JpegEncoder; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg"); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out + } + + #[test] + fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() { + let turned_6 = phone_jpeg(60, 20, 6); + assert_eq!(image_dimensions(&turned_6), Some((20, 60))); + + let turned_8 = phone_jpeg(60, 20, 8); + assert_eq!(image_dimensions(&turned_8), Some((20, 60))); + + let upright_1 = phone_jpeg(60, 20, 1); + assert_eq!(image_dimensions(&upright_1), Some((60, 20))); + } + + #[test] + fn invalid_inputs_return_none() { + assert_eq!(image_dimensions(&[]), None); + assert_eq!(image_dimensions(b"not an image"), None); + } +} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 55eec00959..be7fd5aca5 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -413,6 +413,7 @@ impl Session { | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::EditPageImage { .. } ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6fc0df2f27..6a2bae4ff9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec { out } +fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec()); + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>"); + if rotate != 0 { + page_dict.push_str(&format!(" /Rotate {rotate}")); + } + if (user_unit - 1.0).abs() > 1e-6 { + page_dict.push_str(&format!(" /UserUnit {user_unit}")); + } + page_dict.push_str(" >>"); + objs.push(page_dict.into_bytes()); + let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + fn session_with(n: usize) -> (Session, DocId) { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens"); @@ -6159,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { /// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value /// read the whole form again (twice), so the time grew with the square of the fields. Eight /// times the fields now takes at most about eight times as long (it took about fifty). +/// +/// Each round opens the small form and then the large one, so a busy spell on a shared machine +/// slows both, and the best round's ratio is checked. Timing every small opening before the +/// large ones let such a spell fall on the large ones alone. #[test] fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { - let time = |n: usize| { - let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); - (0..3) - .map(|_| { - let mut s = Session::new(); - let start = std::time::Instant::now(); - s.open("many.pdf", None, bytes.clone(), None).expect("opens"); - start.elapsed() - }) - .min() - .unwrap_or_default() + let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100)); + let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800)); + let time = |bytes: &Arc>| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() }; - let small = time(100); - let large = time(800); - let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); - assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); + // The first openings also pay for what the process sets up once. + time(&small); + time(&large); + let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect(); + let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6); + let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min); + assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)"); } /// A date goes into the XFA data in the field's data picture (``), as other @@ -8102,6 +8138,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() { assert_eq!((w, h), (3, 2)); assert_eq!(rgba, px.into_raw()); assert!(d.page_image_rgba(0, 1).is_err()); + + // When the image is cropped, page_image_at only hit-tests the visible cropped region: + let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0)); + assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive"); } /// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write @@ -8154,6 +8197,105 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } +/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the +/// image's own stream only an extension segment decoders skip, so it has a picture only through +/// them. Its default pixel is black. +#[test] +fn copy_image_reads_the_globals_of_a_jbig2_picture() { + let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + globals.extend_from_slice(&16u32.to_be_bytes()); + globals.extend_from_slice(&2u32.to_be_bytes()); + globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping + let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0]; + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>", + &image, + ), + stream("", &globals), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals"); +} + +/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter +/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`. +#[test] +fn copy_image_copies_a_jbig2_picture_behind_another_filter() { + // 16 × 2: the left half of the top row black, the rest white. + let bits = [0x00, 0xFF, 0xFF, 0xFF]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]", + &hex, + ), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0)); + assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png"); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + let grey: Vec = rgba.chunks(4).map(|px| px[0]).collect(); + let expected: Vec = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect(); + assert_eq!(grey, expected, "the picture, black as 0"); +} + /// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656). fn fixture_without_page_tree() -> Vec { let bytes = fixture(2); @@ -9119,6 +9261,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert_eq!(truncated, [0]); } +/// Moving an added image, then rotating it, must keep the moved position. +#[test] +fn added_image_move_then_rotate_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap(); + let moved = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(moved), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}"); +} + +/// Transforming an added image, then rotating it, must keep the moved position. +#[test] +fn added_image_transform_then_rotate_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + // Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap(); + let transformed = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(transformed), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}"); +} + +/// Moving an added image, then rotating by degrees, must keep the moved position. +#[test] +fn added_image_move_then_rotate_by_keeps_position() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap(); + let moved = rect(&s); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap(); + let rotated = rect(&s); + let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (a, b) = (c(moved), c(rotated)); + assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}"); +} + +/// Arranging an added image moves it into the document stream and converts it to an ordinary +/// page image (dropping the PCAdded marker), so no empty shell remains in list_added. +#[test] +fn added_image_arrange_converts_to_ordinary_image() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap(); + s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); + assert_eq!(s.get(id).unwrap().added().len(), 2); + s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); + assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell"); +} + +#[test] +fn added_image_crop_then_move_keeps_crop() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap(); + let img = s.get(id).unwrap().page_images(0)[0].clone(); + assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0])); +} + +#[test] +fn added_image_move_and_transform_on_rotated_and_user_unit_pages() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let png_bytes = Arc::new(png); + + let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)]; + + for (rotate, user_unit) in cases { + let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens"); + + s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap(); + + // Target user-space rectangle R + let r = [30.0, 40.0, 80.0, 90.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap(); + + let painted = s.get(id).unwrap().page_images(0)[0].rect; + for i in 0..4 { + assert!( + (painted[i] - r[i]).abs() < 1e-3, + "Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}", + painted[i], + r[i] + ); + } + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1); + let stream_obj = match &*cos.get(added[0].obj) { + pdfcraft_cos::Object::Stream(s) => s.clone(), + other => panic!("expected stream, got {other:?}"), + }; + let pc_rect = stream_obj + .dict + .get(b"PCAdded") + .and_then(|o| cos.resolve(o).as_dict().cloned()) + .and_then(|d| d.get(b"Rect").cloned()) + .and_then(|o| cos.resolve(&o).as_array().cloned()) + .unwrap(); + let pc_box: [f64; 4] = + [pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()]; + let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap(); + let re_idx = content.find(" re W n").expect("re W n in content stream"); + let prefix = &content[..re_idx]; + let nums: Vec = + prefix.split_whitespace().rev().take(4).collect::>().into_iter().rev().map(|val| val.parse::().unwrap()).collect(); + assert_eq!(nums.len(), 4); + let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]); + assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]); + assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]); + assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]); + assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]); + + // Transform test: user-space translation by (10, 10) + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap(); + + let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0]; + let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect; + for i in 0..4 { + assert!( + (painted_transformed[i] - expected_transformed[i]).abs() < 1e-3, + "Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}", + painted_transformed[i], + expected_transformed[i] + ); + } + } +} + +#[test] +fn replace_on_added_image_replaces_xobject_and_undo_restores() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([255, 0, 0]); + let png2 = make_png([0, 0, 255]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap(); + + let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap(); + + let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref"); + assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject"); + + s.undo(id).unwrap(); + + let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref"); + assert_eq!(restored_obj, old_obj, "undo restores the old XObject"); + + // Also test replacement on an image with active crop (promoted path) + let png3 = make_png([0, 255, 0]); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap(); + let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref"); + assert_eq!(crop_obj, old_obj); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap(); + let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref"); + assert_ne!(promoted_new_obj, crop_obj); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj); +} + +#[test] +fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([255, 0, 0]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap(); + + let initial_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]); + assert!(initial_img.matrix[0] > 0.0, "initially unflipped"); + + // Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap(); + + let flipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix); + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1); + let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") }; + assert!(ai.flip_h, "PCAdded record must have flip_h = true"); + + // Mirroring horizontally again flips it back + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap(); + let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis"); + let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") }; + assert!(!ai2.flip_h, "PCAdded record must have flip_h = false"); + + // Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0] + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap(); + let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone(); + assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix); + let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") }; + assert!(ai3.flip_v, "PCAdded record must have flip_v = true"); + + // Non-finite transform must be refused with error + let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) }); + assert!(res.is_err(), "NaN transform must be refused"); + + let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) }); + assert!(res_inf.is_err(), "infinite transform must be refused"); + + let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) }); + assert!(res_move.is_err(), "NaN position must be refused"); +} + +#[test] +fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([0, 128, 255]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap(); + + // 1. A pure 90° clockwise rotation matrix about the image centre: + let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap(); + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1, "stays as PCAdded image"); + let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") }; + assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)"); + // Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175] + assert!((ai.rect[0] - 125.0).abs() < 1e-4); + assert!((ai.rect[1] - 75.0).abs() < 1e-4); + assert!((ai.rect[2] - 175.0).abs() < 1e-4); + assert!((ai.rect[3] - 175.0).abs() < 1e-4); + + // 2. A pure 90° counter-clockwise rotation matrix: + let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap(); + + let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") }; + assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0"); + assert!((ai2.rect[0] - 100.0).abs() < 1e-4); + assert!((ai2.rect[1] - 100.0).abs() < 1e-4); + assert!((ai2.rect[2] - 200.0).abs() < 1e-4); + assert!((ai2.rect[3] - 150.0).abs() < 1e-4); + + // Arbitrary transforms promote the added image, just like RotateBy. + let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix; + let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image"); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 1); + let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0; + let actual = s.get(id).unwrap().page_images(0)[0].matrix; + assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}"); + s.undo(id).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability"); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap(); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 1); +} + +#[test] +fn test_added_image_transform_reflections_with_rotation() { + let make_asymmetric_png = || { + let mut px = Vec::with_capacity(20 * 20 * 3); + for y in 0..20 { + for x in 0..20 { + let color = match (x < 10, y < 10) { + (true, true) => [255, 0, 0], // TL: Red + (false, true) => [0, 255, 0], // TR: Green + (true, false) => [0, 0, 255], // BL: Blue + (false, false) => [255, 255, 0], // BR: Yellow + }; + px.extend_from_slice(&color); + } + } + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 20); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + Arc::new(png) + }; + + let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let i = ((y_from_top * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] + }; + + let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50; + let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50; + let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200; + let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50; + + let check_quadrants = + |s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| { + let c_tl = colour_at(s, id, 75, 125); + let c_tr = colour_at(s, id, 125, 125); + let c_bl = colour_at(s, id, 75, 175); + let c_br = colour_at(s, id, 125, 175); + assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl); + assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr); + assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl); + assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br); + }; + + // --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green. + { + // 1a: Flip Horizontal after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal"); + } + + { + // 1b: Flip Vertical after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical"); + } + + // --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue. + { + // 2a: Flip Horizontal after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal"); + } + + { + // 2b: Flip Vertical after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical"); + } +} + /// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb /// field the file leaves to the viewer. The page as displayed and printed as an image shows the /// check box's caption symbol in its text colour, the button's grey face and the comb's cells in @@ -9469,3 +10079,35 @@ fn saving_without_deleting_pages_stays_incremental() { let saved = s.save_bytes(id).unwrap(); assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended"); } + +#[test] +fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() { + let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap(); + let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone(); + let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap()); + pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap(); + let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut session = Session::new(); + let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap(); + let before_added = session.get(id).unwrap().added()[0].content.clone(); + session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata"); + assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]); +} + +#[test] +fn image_transform_engine_refuses_singular_matrices_without_mutation() { + let mut png = Vec::new(); + let mut enc = png::Encoder::new(&mut png, 1, 1); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap(); + let mut s = Session::new(); + let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap(); + let id = s.open("image.pdf", None, bytes, None).unwrap(); + for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] { + let before = s.get(id).unwrap().bytes.clone(); + assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err()); + assert_eq!(s.get(id).unwrap().bytes, before); + } +} diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index 859bc1a6f8..e2b413d88d 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec { } } -/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed. +/// Bounded scan with resolved roots and no descendant symlinks. +/// Only standalone, openly licensed, embeddable faces are listed. /// The caller controls roots (automation resolves confinement before calling this). pub fn discover(roots: &[PathBuf]) -> Vec { + discover_budgeted(roots, 64u64 << 20) +} + +/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total +/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate) +/// does not spend it. Every file read +/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan. +pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec { use std::io::Read; - let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect(); + // User font directories themselves are often links. Resolve only the explicitly + // supplied roots; links found below them must not escape an automation read root. + let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect(); let mut files = Vec::new(); let mut entries = 0usize; - let mut budget = 64u64 << 20; + let mut read_allowance = budget.saturating_mul(4); let mut seen = std::collections::HashSet::new(); + let mut seen_face_content = std::collections::HashSet::new(); while let Some((path, depth)) = pending.pop() { - if entries >= 4096 || budget == 0 { + if entries >= 4096 || budget == 0 || read_allowance == 0 { break; } entries += 1; @@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec { if meta.is_dir() && depth < 4 { if let Ok(children) = std::fs::read_dir(&path) { let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len())); - pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1))); + // Best effort: unreadable entries are skipped and do not count against the entry allowance. + let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect(); + sorted_children.sort(); + pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1))); } } else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) { - if meta.len() > budget { + if meta.len() > budget || meta.len() > read_allowance { continue; } - budget = budget.saturating_sub(meta.len()); + read_allowance = read_allowance.saturating_sub(meta.len()); let mut bytes = Vec::new(); - let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes)); - if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes)); + if read.is_err() || bytes.len() != meta.len() as usize { + continue; + } + if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { continue; } if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { - files.push(FontFile { path, family, face }); + let key = font_content_key(&bytes); + if seen_face_content.insert((face.clone(), key)) { + budget = budget.saturating_sub(meta.len()); + files.push(FontFile { path, family, face }); + } } } } files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); files } + +fn font_content_key(bytes: &[u8]) -> (usize, u64) { + use std::hash::{DefaultHasher, Hasher}; + let mut h = DefaultHasher::new(); + h.write(bytes); + (bytes.len(), h.finish()) +} fn font_extension(path: &Path) -> bool { path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn a_linked_font_root_is_scanned_but_descendant_links_are_not() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id())); + let real = root.join("real"); + std::fs::create_dir_all(&real).unwrap(); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + std::fs::copy(&source, real.join("regular.ttf")).unwrap(); + let link = root.join("linked"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap(); + std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap(); + let found = discover(&[link]); + let expected = real.canonicalize().unwrap().join("regular.ttf"); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found.first().unwrap().face, "Inter-Regular"); + assert_eq!(found.first().unwrap().path, expected); + } + + #[test] + fn discovery_keeps_all_valid_faces_within_its_scan_budget() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + let bytes = std::fs::read(source).unwrap(); + for id in 0..501u32 { + let mut font = bytes.clone(); + // Valid standalone fonts with distinct bytes: an unreferenced trailer + // does not change their tables or embedding licence. + font.extend_from_slice(&id.to_le_bytes()); + std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap(); + } + let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting"); + } + + #[test] + fn test_installed_directories() { + let dirs = installed_directories(); + assert!(!dirs.is_empty()); + } + + #[test] + fn test_font_extension() { + assert!(font_extension(Path::new("Arial.ttf"))); + assert!(font_extension(Path::new("Noto.otf"))); + assert!(font_extension(Path::new("CUSTOM.TTF"))); + assert!(font_extension(Path::new("ROBOTO.OTF"))); + assert!(!font_extension(Path::new("document.pdf"))); + assert!(!font_extension(Path::new("font.woff"))); + assert!(!font_extension(Path::new("font.woff2"))); + assert!(!font_extension(Path::new("no_extension"))); + } + + #[test] + fn test_font_content_key() { + let empty = font_content_key(&[]); + assert_eq!(empty.0, 0); + + let a = font_content_key(b"TrueType font sample data bytes"); + let b = font_content_key(b"TrueType font sample data bytes"); + let c = font_content_key(b"Different font sample data bytes"); + + assert_eq!(a, b, "identical bytes must produce identical keys"); + assert_ne!(a, c, "differing bytes must produce distinct keys"); + assert_eq!(a.0, b"TrueType font sample data bytes".len()); + } + + #[test] + fn test_discover_empty_and_nonexistent_roots() { + let empty = discover(&[]); + assert!(empty.is_empty()); + + let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]); + assert!(nonexistent.is_empty()); + } + + #[test] + fn test_discover_deduplicates_non_adjacent_same_content() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id())); + let dir_a = root.join("a"); + let dir_b = root.join("b"); + let dir_c = root.join("c"); + let _ = std::fs::create_dir_all(&dir_a); + let _ = std::fs::create_dir_all(&dir_b); + let _ = std::fs::create_dir_all(&dir_c); + + let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf"); + let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf"); + + if inter_reg.exists() && inter_med.exists() { + let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf")); + let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf")); + let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf")); + + let discovered = discover(std::slice::from_ref(&root)); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated"); + let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect(); + assert!(faces.contains(&"Inter-Regular")); + assert!(faces.contains(&"Inter-Medium")); + } else { + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() { + let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + + let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts"); + let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len(); + let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len(); + + // An unusable candidate, scanned first (sorted before the real fonts). + std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap(); + std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap(); + + // The budget fits Inter-Regular alone; the rejected file must not use it up first. + let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::>(); + assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::>(), ["Inter-Regular"], "a rejected file spends no budget"); + assert_eq!(faces(&run1), faces(&run2)); + + // Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular. + std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap(); + let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + assert_eq!(one.iter().map(|f| f.face.as_str()).collect::>(), ["Inter-Regular"]); + let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len); + assert_eq!(both.len(), 2); + + // Reading stops at four times the budget, whatever was rejected. + for i in 0..4 { + std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap(); + } + let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10); + let _ = std::fs::remove_dir_all(&root); + assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts"); + } +} diff --git a/crates/engine/src/text_script.rs b/crates/engine/src/text_script.rs new file mode 100644 index 0000000000..04866eda60 --- /dev/null +++ b/crates/engine/src/text_script.rs @@ -0,0 +1,272 @@ +//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and +//! `2` ↔ `₂`. +//! +//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes +//! for raised and lowered text, so the font must have those glyphs, and extracted text contains +//! them. Characters Unicode has no such form for are left as they are. +//! +//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`, +//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles +//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original +//! mixture. Restoring that exact prior text requires the caller's edit history (Undo). + +use std::ops::Range; + +/// Raised or lowered text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Script { + Super, + Sub, +} + +/// Each character Unicode raises, and its superscript form. +const SUPERSCRIPT: &[(char, char)] = &[ + ('0', '⁰'), + ('1', '¹'), + ('2', '²'), + ('3', '³'), + ('4', '⁴'), + ('5', '⁵'), + ('6', '⁶'), + ('7', '⁷'), + ('8', '⁸'), + ('9', '⁹'), + ('+', '⁺'), + ('−', '⁻'), + ('=', '⁼'), + ('(', '⁽'), + (')', '⁾'), + ('a', 'ᵃ'), + ('b', 'ᵇ'), + ('c', 'ᶜ'), + ('d', 'ᵈ'), + ('e', 'ᵉ'), + ('f', 'ᶠ'), + ('g', 'ᵍ'), + ('h', 'ʰ'), + ('i', 'ⁱ'), + ('j', 'ʲ'), + ('k', 'ᵏ'), + ('l', 'ˡ'), + ('m', 'ᵐ'), + ('n', 'ⁿ'), + ('o', 'ᵒ'), + ('p', 'ᵖ'), + ('r', 'ʳ'), + ('s', 'ˢ'), + ('t', 'ᵗ'), + ('u', 'ᵘ'), + ('v', 'ᵛ'), + ('w', 'ʷ'), + ('x', 'ˣ'), + ('y', 'ʸ'), + ('z', 'ᶻ'), + ('A', 'ᴬ'), + ('B', 'ᴮ'), + ('D', 'ᴰ'), + ('E', 'ᴱ'), + ('G', 'ᴳ'), + ('H', 'ᴴ'), + ('I', 'ᴵ'), + ('J', 'ᴶ'), + ('K', 'ᴷ'), + ('L', 'ᴸ'), + ('M', 'ᴹ'), + ('N', 'ᴺ'), + ('O', 'ᴼ'), + ('P', 'ᴾ'), + ('R', 'ᴿ'), + ('T', 'ᵀ'), + ('U', 'ᵁ'), + ('V', 'ⱽ'), + ('W', 'ᵂ'), +]; + +/// Each character Unicode lowers, and its subscript form. +const SUBSCRIPT: &[(char, char)] = &[ + ('0', '₀'), + ('1', '₁'), + ('2', '₂'), + ('3', '₃'), + ('4', '₄'), + ('5', '₅'), + ('6', '₆'), + ('7', '₇'), + ('8', '₈'), + ('9', '₉'), + ('+', '₊'), + ('−', '₋'), + ('=', '₌'), + ('(', '₍'), + (')', '₎'), + ('a', 'ₐ'), + ('e', 'ₑ'), + ('h', 'ₕ'), + ('i', 'ᵢ'), + ('j', 'ⱼ'), + ('k', 'ₖ'), + ('l', 'ₗ'), + ('m', 'ₘ'), + ('n', 'ₙ'), + ('o', 'ₒ'), + ('p', 'ₚ'), + ('r', 'ᵣ'), + ('s', 'ₛ'), + ('t', 'ₜ'), + ('u', 'ᵤ'), + ('v', 'ᵥ'), + ('x', 'ₓ'), +]; + +impl Script { + fn pairs(self) -> &'static [(char, char)] { + match self { + Script::Super => SUPERSCRIPT, + Script::Sub => SUBSCRIPT, + } + } + + /// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it. + /// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision. + pub fn form(self, c: char) -> Option { + self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form) + } + + /// The character `c` is the raised or lowered form of, or `None` when it is no such form. + pub fn plain(self, c: char) -> Option { + self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain) + } +} + +/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text` +/// when there is none. +fn span(text: &str, range: Option<&Range>) -> Range { + let Some(range) = range else { return 0..text.len() }; + let mut start = range.start.min(text.len()); + while !text.is_char_boundary(start) { + start = start.saturating_sub(1); + } + let mut end = range.end.min(text.len()); + while !text.is_char_boundary(end) { + end = end.saturating_add(1); + } + start..end +} + +/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in +/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no +/// form for (a space, a `q`) don't count toward that state. +pub fn is_in(text: &str, range: Option<&Range>, script: Script) -> bool { + let target = text.get(span(text, range)).unwrap_or_default(); + target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none()) +} + +/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when +/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise +/// each character that has a form takes it. Returns the new text and the range the converted +/// characters now take, or `None` when nothing changes. +/// +/// Mixed plain/scripted input normalizes to the target script and then to plain text; for +/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original +/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo. +pub fn toggle(text: &str, range: Option<&Range>, script: Script) -> Option<(String, Range)> { + let span = span(text, range); + let target = text.get(span.clone()).filter(|t| !t.is_empty())?; + let converted: String = if is_in(text, Some(&span), script) { + target.chars().map(|c| script.plain(c).unwrap_or(c)).collect() + } else { + target.chars().map(|c| script.form(c).unwrap_or(c)).collect() + }; + if converted == target { + return None; + } + let mut out = String::with_capacity(text.len().saturating_add(converted.len())); + out.push_str(text.get(..span.start).unwrap_or_default()); + out.push_str(&converted); + out.push_str(text.get(span.end..).unwrap_or_default()); + let end = span.start.saturating_add(converted.len()); + Some((out, span.start..end)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_form_toggles_back_to_its_character() { + for script in [Script::Super, Script::Sub] { + for &(plain, form) in script.pairs() { + let s = plain.to_string(); + let (raised, range) = toggle(&s, None, script).unwrap(); + assert_eq!(raised, form.to_string()); + assert_eq!(range, 0..raised.len()); + assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}"); + } + } + assert_eq!(toggle("-", None, Script::Super), None); + assert_eq!(toggle("-", None, Script::Sub), None); + } + + #[test] + fn review_script_round_trips_preserve_both_minus_characters() { + for script in [Script::Super, Script::Sub] { + let text = "−2 -3"; + let once = toggle(text, None, script).unwrap().0; + assert_eq!(toggle(&once, None, script).unwrap().0, text); + } + } + + #[test] + fn text_with_characters_unicode_cannot_raise_toggles_back() { + // q has no superscript form and Q no subscript one; the space neither. + for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] { + let (once, _) = toggle(text, None, script).unwrap(); + assert_eq!(once, toggled); + assert!(is_in(&once, None, script)); + assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}"); + } + // Nothing to raise: nothing changes. + assert_eq!(toggle("q Q", None, Script::Super), None); + assert!(!is_in("q Q", None, Script::Super)); + } + + #[test] + fn a_range_converts_only_its_characters_and_follows_them() { + let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap(); + assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8)); + assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7)); + // A mixed range takes the form throughout. + assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂"); + assert!(!is_in("H₂2", None, Script::Sub)); + } + + #[test] + fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() { + for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] { + assert!(!is_in(mixed, None, script)); + let (once, range) = toggle(mixed, None, script).unwrap(); + assert_eq!(once, uniform); + assert_eq!(range, 0..uniform.len()); + assert!(is_in(&once, None, script)); + let (twice, range) = toggle(&once, None, script).unwrap(); + assert_eq!(twice, plain); + assert_eq!(range, 0..plain.len()); + assert_ne!(twice, mixed, "a second toggle normalizes the original mixture"); + assert_eq!(toggle(&twice, None, script).unwrap().0, uniform); + } + } + + #[test] + fn hostile_ranges_are_clamped_to_whole_characters() { + // Byte 1 is inside ²: widened to the whole character on each side. + assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1)); + assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None); + assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3)); + assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 2..1; + assert_eq!(toggle("x2", Some(&backwards), Script::Super), None); + assert_eq!(toggle("", None, Script::Sub), None); + assert!(!is_in("", None, Script::Sub)); + } +} diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index aa8aaa0c4d..bfb930fe5f 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding, JBIG2 encoding and decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -12,13 +12,12 @@ workspace = true [dependencies] fax.workspace = true -# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro. +# CCITT fax and JBIG2 decoding (`decode_ccitt`, `decode_jbig2`); already in the tree through hayro. hayro-ccitt.workspace = true +hayro-jbig2.workspace = true flate2.workspace = true thiserror.workspace = true [dev-dependencies] -hayro-ccitt.workspace = true -hayro-jbig2.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 0c34857c43..59403e4a2b 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,7 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. @@ -26,6 +26,7 @@ encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS +decode_jbig2(data, globals, columns, rows, max) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS ``` @@ -47,7 +48,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | +| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, about the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::Document::decoded_image` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests @@ -57,12 +58,13 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/decode.rs`: `decode_jbig2` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. - `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the DCT and JPX decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index e87a82fe16..7c188d701c 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -132,9 +132,10 @@ fn starts_with_end_of_line(data: &[u8]) -> bool { } /// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at -/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. -struct Rows { - out: Vec, +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder +/// ([`crate::decode_jbig2`]) fills them too. +pub(crate) struct Rows { + pub(crate) out: Vec, row: Vec, x: usize, columns: usize, @@ -144,12 +145,12 @@ struct Rows { } impl Rows { - fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + pub(crate) fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } } /// `count` pixels, white or black. - fn push(&mut self, white: bool, count: usize) { + pub(crate) fn push(&mut self, white: bool, count: usize) { let end = self.x.saturating_add(count); if white != self.black_is_1 { for x in self.x..end.min(self.columns) { @@ -160,6 +161,16 @@ impl Rows { } self.x = end; } + + /// The row filled so far kept, if fewer than `rows` are, and the next one begun. + pub(crate) fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } } impl hayro_ccitt::Decoder for Rows { @@ -170,12 +181,7 @@ impl hayro_ccitt::Decoder for Rows { self.push(white, (chunks as usize).saturating_mul(8)); } fn next_line(&mut self) { - if self.done < self.rows { - self.out.extend_from_slice(&self.row); - self.done += 1; - } - self.row.fill(0); - self.x = 0; + self.end_row(); } } diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 343fbbd27d..a36a81e1fb 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -2,10 +2,13 @@ //! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ //! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from //! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 -//! (the segments) and D.3 (the embedded organisation PDF uses). +//! (the segments) and D.3 (the embedded organisation PDF uses). Decoding is in [`decode`]. +mod decode; mod symbols; +pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2}; + use crate::FilterError; use crate::mq::{Context, MqEncoder}; diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs new file mode 100644 index 0000000000..320e02445a --- /dev/null +++ b/crates/filters/src/jbig2/decode.rs @@ -0,0 +1,157 @@ +//! JBIG2 decoding for `JBIG2Decode` (ISO 32000-2 §7.4.7), through `hayro-jbig2`. + +use crate::FilterError; +use crate::ccitt::Rows; + +/// The largest JBIG2 page [`decode_jbig2`] decodes, in pixels. The decoder decodes a page whole, +/// and takes pages of up to about this many (20,000 × 10,000 decodes). +pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; + +/// A `JBIG2Decode` stream's `data`, with what its `/JBIG2Globals` stream decodes to, decoded as the +/// filter gives it: `rows` rows of `columns` 1-bit samples, most significant bit first, each +/// starting on a new byte, with black as 0 (JBIG2 codes black as 1). +/// +/// The JBIG2 page has to be the image's size, `columns` × `rows`: PdfCraft's renderer draws the +/// whole page into the image, so the rows of a page of another height wouldn't be the image's. The +/// decoder decodes the page whole, so it is checked before it is decoded, and has to fit in `max` +/// bytes as such rows and have at most [`MAX_JBIG2_PIXELS`]. Regions inside it are bounded by the +/// decoder alone: a region may claim more pixels than its page, and is decoded before it is cut to +/// the page. With no rows asked for, nothing is decoded. +/// +/// Errors: `data` or `globals` that don't decode, or a page of another size +/// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or +/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the +/// same cap and can refuse the page first). +pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { + if rows == 0 { + return Ok(Vec::new()); + } + let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; + let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; + let (width, height) = (image.width(), image.height()); + if (width, height) != (columns, rows) { + return Err(corrupt(format!("a page of {width} × {height} pixels for an image of {columns} × {rows}"))); + } + if u64::from(width) * u64::from(height) > MAX_JBIG2_PIXELS { + return Err(FilterError::Unsupported(format!("JBIG2 pages of {width} × {height} pixels"))); + } + // u32 to usize: wasm32 and up have at least 32-bit usize. + let (width, height) = (width as usize, height as usize); + if width.div_ceil(8).checked_mul(height).is_none_or(|bytes| bytes > max) { + return Err(FilterError::LimitExceeded(max)); + } + let mut out = Rows::new(width, height, false); + image.decode(&mut out).map_err(|e| corrupt(e.to_string()))?; + Ok(out.out) +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{encode_jbig2, encode_jbig2_generic}; + + /// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0. + fn noise(columns: u32, rows: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut data: Vec = (0..stride * rows as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let pad = (stride * 8 - columns as usize) as u32; + data.chunks_mut(stride).for_each(|row| *row.last_mut().unwrap() &= 0xFFu8.checked_shl(pad).unwrap_or(0)); + data + } + + /// The same stream with its page information (the first segment's data, after its 11-byte + /// header: width, then height) claiming a page of `width` × `height`. + fn page_of(data: &[u8], width: u32, height: u32) -> Vec { + let mut data = data.to_vec(); + data[11..15].copy_from_slice(&width.to_be_bytes()); + data[15..19].copy_from_slice(&height.to_be_bytes()); + data + } + + /// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic + /// region of noise, and symbols for letters repeated across the page. With no rows asked for, + /// nothing is decoded. + #[test] + fn both_codings_decode_to_their_samples() { + let (w, h) = (203u32, 41u32); + let bits = noise(w, h); + let generic = encode_jbig2_generic(&bits, w, h).unwrap(); + assert_eq!(decode_jbig2(&generic, None, w, h, usize::MAX).unwrap(), bits); + assert_eq!(decode_jbig2(b"not JBIG2", None, w, 0, usize::MAX).unwrap(), b"", "no rows asked for"); + + // Four lines of three letters, black on white. + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400u32, 40u32); + let stride = (w as usize).div_ceil(8); + let mut marks = vec![0xFFu8; stride * h as usize]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w as usize - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + marks[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let symbols = encode_jbig2(&marks, w, h).unwrap(); + assert!(symbols.len() < encode_jbig2_generic(&marks, w, h).unwrap().len(), "symbol coded"); + assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); + } + + /// The page has to be the image's size, and is bounded before it is decoded, being decoded + /// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given + /// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged + /// data, which never panics. + #[test] + fn what_it_cannot_decode_is_an_error() { + let (w, h) = (203u32, 41u32); + let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap(); + for (columns, rows) in [(w - 1, h), (w + 1, h), (w, h - 1), (w, h + 1)] { + let e = decode_jbig2(&data, None, columns, rows, usize::MAX).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{columns} × {rows}: {e}"); + } + let page = 26 * h as usize; + assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short"); + assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max"); + // 16,384 × 16,384 is 2^28 pixels, which decodes; a row more is past the cap, here or in the + // decoder, which has the same one and can refuse it first. + let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX); + assert!(matches!(large(16_385), Err(FilterError::Unsupported(_) | FilterError::Corrupt { .. })), "{:?}", large(16_385)); + assert!(large(16_384).is_ok(), "{:?}", large(16_384).map(|page| page.len())); + + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header + // gives the length of the data after it. + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let generic = decode_jbig2(&data, None, w, h, usize::MAX).unwrap(); + assert_eq!(decode_jbig2(&data, Some(&skipped), w, h, usize::MAX).unwrap(), generic); + assert!(matches!(decode_jbig2(&data, Some(b"not JBIG2"), w, h, usize::MAX), Err(FilterError::Corrupt { .. }))); + for end in 0..data.len() { + let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 7eac1a83e1..ba455e2bae 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,9 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own +//! ([`decode_ccitt`], [`decode_jbig2`]), as `pdfcraft_cos::Document::decoded_image` +//! does after the filters before them. 1-bit samples can be encoded as CCITT Group 4 //! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb @@ -27,7 +29,7 @@ mod runlength; use std::borrow::Cow; pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9f96b17218..27cada0d2f 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -31,7 +31,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone by these colour and grey settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, - JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with + JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by @@ -40,7 +40,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones - or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further + or with CCITT fax or JBIG2 after any of those (decoded by `Document::decoded_image_within`, no further than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's @@ -59,24 +59,38 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going downsampling it to JPEG. - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a - tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image - mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and - entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). - Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, - most of whose new pixels would cover both black and white, so that taking the sample covering - more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than - a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each - column. On a 600 ppi text page, Group 4 at 300 ppi is half the size. + tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image + mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two + palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and + RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no + decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or + `/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form + resources read, as an annotation's appearance, may be). Not one with a colour key, which can + hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to + follow; not a halftone or a dither, most of whose new pixels would cover both black and white, + so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% + one white), nor one with a square of four new pixels that each would, as a small screened + picture on a white page has: ragged scanned text has such squares along its strokes too, and + is then left at its resolution. And not one wider than a fax row + (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each column. On a + 600 ppi page of text that is averaged, Group 4 at 300 ppi is about half the size. - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that - is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder recovers it, a bad row picked up again at the next code, as it reports no damage there; without - them, damage stops the decoding and the image is left alone. With Retain, a resampled image - keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits - is then left to the grey or colour settings. Every change is kept only when it is smaller as - stored. + them, damage stops the decoding and the image is left alone. JBIG2 inside another filter, or + alone when JBIG2 is asked for, is stored again as the compression asks when that is smaller (as JBIG2 symbols where + its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact + repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no + `/JBIG2Globals`, and one whose page isn't its size (PdfCraft's + renderer draws the whole page into the image) is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, JBIG2, or Flate) and nothing else changes; an image of + more bits is then left to the grey or colour settings. Every change is kept only when it is + smaller as stored. - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit @@ -85,8 +99,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going samples. - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when - smaller. No lossy symbol substitution is used. Export reads embedded images and bounded - shared globals. + smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 + images, with bounded shared globals. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9338061978..613cc3f26f 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,6 +1,7 @@ //! Image resampling and recompression. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; @@ -300,6 +301,19 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) } +/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]), +/// which default colour spaces in force where it is drawn may change. +fn indexed(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed"))) +} + +/// The first entry of colour space array `a`, its family, read through a reference. +fn family(doc: &Document, a: &[Object]) -> Option> { + a.first().map(|f| doc.resolve(f)) +} + /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary /// too, but not those in objects referred to. fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { @@ -449,7 +463,7 @@ fn is_dct(s: &Stream) -> Option { #[cfg(test)] thread_local! { - /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + /// Pixels decoded, for the tests that keep an image from being decoded for nothing. static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -618,13 +632,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option< } pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { - use image::imageops::{FilterType, resize}; Some(match n { - 1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), - _ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), + 1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), + _ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), }) } +/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at +/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than +/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the +/// process. Such a one is scaled across first, which holds no more pixels than one of the two has. +/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as +/// it was. +fn scaled + 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer> { + use image::imageops::{FilterType, resize}; + let (iw, ih) = (u64::from(img.width()), u64::from(img.height())); + let largest = (iw * ih).max(u64::from(w) * u64::from(h)); + if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom) + } else { + resize(img, w, h, FilterType::CatmullRom) + } +} + fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -754,7 +784,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool { let cs = doc.resolve(cs); match &*cs { Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), - Object::Array(a) => match a.first().and_then(Object::as_name) { + Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) { Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), @@ -771,7 +801,7 @@ type Pixel = fn(&[u8]) -> Option; /// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit /// image. These count: /// - one bit per pixel in one colour component, or an image mask, stored with no filter, with -/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further /// than its rows can need); /// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): /// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array @@ -802,8 +832,10 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve let len = stride.checked_mul(hu)?; let max = match s.filters().last() { Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + // A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit. _ => allowance(len, h)?, }; + count_decoded(u64::from(w) * u64::from(h)); let mut data = doc.decoded_image_within(s, max).ok()?; if data.len() < len { return None; @@ -827,7 +859,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); // Components a pixel, bits a component, and a pixel's samples as 1 or 0. let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { - Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; match decode.as_deref() { None => {} @@ -908,8 +940,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit Some(out) } -/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom -/// colour spaces stay at their original resolution rather than guessing which entry is dark. +/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array +/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so +/// is the painted part of an image mask. An indexed image's samples pick palette entries (its +/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and +/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and +/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike. fn dark_is_one(doc: &Document, d: &Dict) -> Option { let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { @@ -923,12 +959,62 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { return Some(d1 < d0); } + // A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the + // sample that shows. + if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) { + return None; + } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => { + let hival = a.get(2).and_then(|v| match &*doc.resolve(v) { + Object::Int(h) if (0..=255).contains(h) => Some(*h), + _ => None, + })?; + // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. + let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; + let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); + (l0 != l1).then_some(l1 < l0) + } _ => None, } } +/// The most bytes a palette is read to (see [`lightness`]). +const MAX_PALETTE: usize = 4096; + +/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) +/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only +/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or +/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base +/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. +fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { + // A device space is its name, or that name alone in an array. + let base = doc.resolve(a.get(1)?); + let base = match &*base { + Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?), + _ => base, + }; + let n = match base.as_name()? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + _ => return None, + }; + // At most 256 entries of three components: more is no palette. Images share a table, and this + // is read for each of them, so a large one is refused before it's copied or decoded. + let table = match &*doc.resolve(a.get(3)?) { + Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(), + Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?, + _ => return None, + }; + let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); + Some(match c.as_slice() { + [v] => v * 1000, + [r, g, b] => 299 * r + 587 * g + 114 * b, + _ => return None, + }) +} + /// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: /// each new pixel takes the sample that covers more of its area, and the dark one when the two tie /// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` @@ -1000,15 +1086,20 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. /// /// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, -/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a -/// dither: most of its new pixels would cover both black and white, and taking the sample that -/// covers more would turn its tones solid (a 50% screen black, a 25% one white). -/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only -/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again -/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored -/// again like any other. With `Retain`, a resampled image keeps its kind of compression -/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the -/// grey or colour settings. +/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose +/// palette says which entry is darker, not under `defaults`), unless it has a soft mask, whose +/// own size would then have to follow, or is a halftone or a dither: most of its new pixels would +/// cover both black and white, and taking the sample that covers more would turn its tones solid +/// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover +/// both, as a small screened picture on a white page has (and ragged scanned text, which is then +/// left at its resolution too). +/// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that +/// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image +/// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded +/// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside +/// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or +/// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or +/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`. /// /// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for /// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default @@ -1020,10 +1111,14 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet let fax = doc.fax_params(s); // Its kind of compression, which Retain keeps, behind any other filters. let ccitt = fax.is_some(); - // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind - // ASCII85Decode, say, can be smaller stored again as Group 4 alone. - let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample { + let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))); + // Group 4 alone is only worth decoding to resample it, and so is JBIG2 alone when Group 4 is what + // it would be stored as: that output is rarely smaller, and decoding an ordinary scan costs a + // quarter of a second a page. Group 3, Group 4 behind ASCII85Decode, say, and JBIG2 stored as + // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone. + let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4; + let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0))); + if only_to_resample && !downsample { return None; } let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; @@ -1038,8 +1133,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { (w, h) }; - let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; - let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if polarity.is_some() { // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; // A halftone or dither: most new pixels cover both samples. Text covers both only along @@ -1055,7 +1150,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet Some(out) => (out, nw, nh), None => (data, w, h), }; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled { + if only_to_resample && !resampled { return None; } let mut dict = packed.unwrap_or_else(|| d.clone()); @@ -1078,7 +1173,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let jbig = if settings.compression == MonoCompression::Jbig2 { + let jbig = if settings.compression == MonoCompression::Jbig2 || (retain && jbig2) { pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| { let mut dict = dict.clone(); dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); @@ -1087,7 +1182,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data)); // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) @@ -1101,11 +1196,13 @@ pub(crate) fn run( progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { let (ppi, mut defaults, read) = walk_pages(doc, pages); - // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used - // where the walk didn't look may be drawn so. + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an + // indexed image's palette says which of its samples a resampled one keeps, which they may + // change: one used where the walk didn't look may be drawn so. let packs = settings.mono.compression != MonoCompression::Retain; - let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); - defaults.extend(used_elsewhere(doc, &rgb, &read)); + let checked: HashSet = + ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &checked, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() @@ -1228,6 +1325,36 @@ mod dimension_tests { assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); } + /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped, + /// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind + /// another filter it can still be stored again, as Group 4 can. + #[test] + fn a_jbig2_image_alone_is_not_decoded_for_group_4_unless_it_is_resampled() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let image = |filter: Object, data: Vec| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), filter); + Stream::from_raw(dict, data) + }; + let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }; + let decoded = |s: &Stream, settings: &MonoSettings| { + DECODED_PIXELS.with(|c| c.set(0)); + mono(&Document::new_empty(), s, 300.0, false, settings); + DECODED_PIXELS.with(|c| c.get()) + }; + let alone = image(Object::name("JBIG2Decode"), coded.clone()); + assert_eq!(decoded(&alone, &settings), 0, "alone, for Group 4, below the threshold"); + let behind = image(Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]), hex); + assert!(decoded(&behind, &settings) > 0, "behind another filter it can be stored again"); + let jbig2 = MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }; + assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller"); + } + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { @@ -1244,6 +1371,66 @@ mod dimension_tests { assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); } + + /// Resampling goes through no more than one of the two sizes holds, whatever their proportions: + /// the first three shapes asked for 160 GB and more on the way, and that aborted the process. + /// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way + /// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions + /// resample as they always did. + #[test] + fn resampling_across_proportions_takes_the_memory_of_neither_product() { + let shapes = [ + (100_000u32, 1u32, 1u32, 100_000u32, 1usize), + (1_000_000, 1, 4, 100_000, 1), + (100_000, 1, 1, 100_000, 3), + (1, 100_000, 100_000, 1, 1), + (1, 100_000, 100_000, 1, 3), + ]; + for (w, h, nw, nh, n) in shapes { + let px = vec![0x60; (w * h) as usize * n]; + let out = resample(&px, n, w, h, nw, nh).unwrap(); + assert_eq!(out.len(), (nw * nh) as usize * n); + assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}"); + } + let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32); + let px: Vec = (0..w * h).map(|i| (i * 7 % 251) as u8).collect(); + let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap(); + let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw(); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was"); + } + + /// The long way round is for proportions far apart: up to a quarter over the larger picture the + /// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes + /// out the same either way. + #[test] + fn proportions_up_to_the_limit_resample_as_they_did() { + let noise = |w: u32, h: u32| -> Vec { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect() + }; + let direct = |px: &[u8], w, h, nw, nh| { + let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap(); + image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let px = noise(w, h); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}"); + } + // A step past it goes the long way, which is how to tell. + let px = noise(25, 15); + assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20)); + // A picture of the size asked for comes through as it is. + let px = noise(20, 10); + assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px); + } } #[cfg(test)] @@ -1307,8 +1494,9 @@ mod downsample_tests { assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); } - /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round - /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + /// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode + /// array; none known for a separation, nor for an indexed image without a palette (see the next + /// test), nor with a decode array that isn't two numbers. #[test] fn the_dark_sample_follows_the_colour_space_and_decode_array() { let doc = Document::new_empty(); @@ -1332,6 +1520,7 @@ mod downsample_tests { (vec![mask, inverted.clone()], Some(true)), (vec![separation.clone()], None), (vec![separation, inverted.clone()], None), + // A palette that isn't there says nothing. (vec![indexed.clone()], None), (vec![indexed, inverted], None), (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), @@ -1340,6 +1529,54 @@ mod downsample_tests { } } + /// An indexed image's dark sample is the one picking the darker palette entry, after its decode + /// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string + /// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a + /// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255 + /// say nothing. + #[test] + fn an_indexed_images_palette_says_which_sample_is_dark() { + let mut doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec())); + let referenced = doc.add(string(&[255, 0])); + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255]))); + let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]); + let indexed = |base: Object, hival: i64, table: Object| { + let mut d = Dict::new(); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table])); + d + }; + let inverted = |mut d: Dict| { + d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()])); + d + }; + for (d, dark_one, what) in [ + (indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"), + (indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"), + (indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"), + (inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"), + (indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"), + (indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"), + // Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685). + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"), + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"), + // Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it. + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"), + (indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"), + (indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"), + (indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"), + (indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"), + (indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"), + (indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"), + (indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"), + (indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"), + ] { + assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}"); + } + } + #[test] fn what_it_cannot_resample_is_none() { assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 76e30da7ac..4c8845c82d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -10,10 +10,11 @@ //! A new image replaces the old one only if it is smaller. The colour and grey settings leave //! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays //! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker -//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks, CCITT fax and JBIG2, //! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images //! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a -//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and +//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 683cbd0684..5bfa74aa8b 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2045,6 +2045,127 @@ fn rendered_text_becomes_jbig2_symbols() { assert!(render(&doc) == before, "the page looks the same"); } +/// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it +/// is averaged as its Flate copy is, to the same samples, and stored as each compression asks: +/// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another +/// filter, and with `/JBIG2Globals`, which the new image doesn't refer to. Below 450 ppi it is +/// stored again only when that is smaller: a generic region of text stays under Group 4, which +/// is larger, and under Retain, and becomes symbols with JBIG2 asked for; behind ASCIIHex it is +/// stored again alone, as Group 4 where the hex is spaced out. A JBIG2 page taller than the image +/// is left alone. +#[test] +fn jbig2_scans_are_averaged_and_stored_again() { + // Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new + // pixel covering both samples, so nothing keeps the picture at its resolution. + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut squares = vec![0xFFu8; stride * h as usize]; + for (top, left) in (0..h as usize / 32).flat_map(|r| (0..w as usize / 32).map(move |c| (r * 32 + 8, c * 32 + 8))) { + for y in top..top + 16 { + for x in left..left + 16 { + squares[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let hex = |coded: &[u8]| -> Vec { coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() }; + // An image of `coded` behind `filters`, with globals a decoder skips when `globals`, drawn at + // `width` × `height` pt. + let jbig2_page = |coded: &[u8], filters: &[&str], globals: bool, (iw, ih): (u32, u32), (width, height): (f64, f64)| { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(iw.into())); + d.set(b"Height".to_vec(), Object::Int(ih.into())); + for (k, v) in &grey { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let entries = filters.iter().map(|f| if *f == "JBIG2Decode" { Object::Dict(parms.clone()) } else { Object::Null }).collect(); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + let data = if filters.len() > 1 { hex(coded) } else { coded.to_vec() }; + let im = doc.add(Object::Stream(Stream::from_raw(d, data))); + page(&mut doc, &[("Im", im)], &format!("q {width} 0 0 {height} 20 600 cm /Im Do Q")); + (doc, im) + }; + let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap(); + // 144 × 57.6 pt is 600 ppi. + let at_600 = (144.0, 57.6); + for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut doc = Document::new_empty(); + let im = mono_image(&mut doc, w, h, &squares, None, &grey); + page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}"); + let averaged = doc.decoded_image(&stream(&doc, im)).unwrap(); + for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] { + let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600); + let r = optimize(&mut doc, &settings).unwrap(); + let s = stream(&doc, im); + let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}"); + assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}"); + assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}"); + assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}"); + let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec); + // The smallest of what each compression may store; under Retain, JBIG2 again. + let allowed: &[&[u8]] = match compression { + MonoCompression::CcittG4 => &[b"CCITTFaxDecode", b"FlateDecode"], + MonoCompression::Jbig2 => &[b"JBIG2Decode", b"FlateDecode"], + MonoCompression::Retain => &[b"JBIG2Decode"], + }; + assert!(filter.as_deref().is_some_and(|f| allowed.contains(&f)), "{what}: {:?}", filter.map(String::from_utf8)); + } + } + + // At 300 ppi, below 450: rendered text, whose letters repeat, coded as one generic region. + let (tw, th) = (600u32, 480u32); + let text = rendered_text(tw, th); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, tw, th).unwrap(); + let at_300 = (144.0, 115.2); + for compression in [MonoCompression::CcittG4, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let before = stream(&doc, im); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, im), before, "JBIG2 below 450 ppi, {compression:?}"); + } + let jbig2_asked = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let r = optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}"); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len()); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + // Behind ASCIIHex it is stored again alone, being smaller so. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..]))); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + // So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex + // allows, so that the stream is many times the code. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + let mut padded = stream(&doc, im); + padded.raw = generic.iter().flat_map(|b| format!("{b:02x}{}", " ".repeat(30)).into_bytes()).chain(*b">").collect::>().into(); + doc.set(im, Object::Stream(padded)); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone"); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + + // A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't + // read, and stays as it is. + let (mut doc, im) = jbig2_page(&coded, &["JBIG2Decode"], false, (w, h - 8), at_600); + let before = stream(&doc, im); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, im), before, "a taller page"); +} + /// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the /// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. #[test] @@ -3394,10 +3515,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } -/// Unknown palette polarity is never guessed. Both palette orders and Decode directions -/// retain the original thin rule and exact samples through a full save and render. +/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does +/// in grey: with the palette either way round, and the decode array either way, the averaged image +/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same +/// through a full save and render. #[test] -fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { +fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { let (w, h) = (1200u32, 480u32); let stride = (w as usize).div_ceil(8); let mut gray_data = vec![0xFF; stride * h as usize]; @@ -3409,6 +3532,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); let gray_render = render(&gray); optimize(&mut gray, &Settings::default()).unwrap(); + let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap()); let gray_stream = stream(&gray, gray_image); let gray_samples = gray.decoded_image(&gray_stream).unwrap(); assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); @@ -3440,14 +3564,153 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, image); - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); - assert_eq!(doc.decoded_image(&s).unwrap(), data); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}"); + // The rows are 600 samples, whole bytes: flipping each byte flips each sample. + let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect(); + assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); } } } +/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where +/// other default colour spaces may be in force and change which palette entry is darker) keeps its +/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is +/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an +/// indexed image's palette depends on what's in force where it is drawn. +#[test] +fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]); + let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)]; + for (cs, in_annotation, width) in cases { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]); + // 144 × 57.6 pt is 600 ppi. + let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + if in_annotation { + let resources = Object::Dict({ + let mut r = Dict::new(); + r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)]))); + r + }); + let appearance = drawing(&mut doc, Some(resources), &form_entries()); + annotate(&mut doc, p, appearance); + } + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}"); + } +} + +/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than +/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has +/// no palette, so its samples aren't averaged. +#[test] +fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0])); + let optimized = |space: &str| { + let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + (s.dict.int(b"Width"), doc.decoded_image(&s).unwrap()) + }; + let (width, samples) = optimized("Indexed"); + assert_eq!(width, Some(600)); + assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample"); + assert_eq!(optimized("NotIndexed").0, Some(1200)); +} + +/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base +/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival` +/// aren't one, and neither is a table too large to be a palette, which images share and which +/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of +/// the other would be lost, in grey too; a stencil `/Mask` doesn't. +#[test] +fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]); + let rgb = || Object::name("DeviceRGB"); + let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table()); + let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| { + let mut doc = Document::new_empty(); + let mut entries = vec![("BitsPerComponent", Object::Int(1))]; + entries.extend(make(&mut doc)); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + stream(&doc, image).dict.int(b"Width") + }; + let averaged = Some(600); + let kept = Some(1200); + assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct"); + assert_eq!( + width(&|doc: &mut Document| { + let family = doc.add(Object::name("Indexed")); + vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))] + }), + averaged, + "the family by reference" + ); + let base = Object::Array(vec![rgb()]); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); + let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + // 16 KiB of raw data is all a table may take, whatever it decodes to. + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); + let table_of = |raw: usize| { + let d = d.clone(); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect(); + move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))) + }; + for (raw, expected) in [(16384, averaged), (16385, kept)] { + let add = table_of(raw); + assert_eq!( + width(&|doc: &mut Document| { + let table = add(doc); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))] + }), + expected, + "a stream of {raw} bytes" + ); + } + let key = || Object::Array(vec![0.into(), 0.into()]); + assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); + assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey"); + assert_eq!( + width(&|doc: &mut Document| { + let key = doc.add(key()); + vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))] + }), + kept, + "a colour key by reference" + ); +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 8d1515d8a4..fe21d13b32 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -17,8 +17,9 @@ pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } +# CCITT Group 4 and JBIG2 encoding for cleared one-bit images. +pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 8383f0119f..9c024d1b55 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,10 +18,19 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared - (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last - filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference, - numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed - whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as + the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its + `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy + is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 + when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to + no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), + those of marks under the area included, so every other image that uses the globals of a removed + or replaced image is decoded and stored again in place without them, as a copy is (Flate, + Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be + decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are + removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer + draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more + than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes to more than three times their declared raster @@ -103,5 +112,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. Custom code sets aren't supported yet. -Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead). +Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed +instead). diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index f662849c12..a96f4715e6 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,13 +1,14 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is -//! written as a new Flate image; the original object is untouched (other pages may use it). -//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes, -//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode -//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image -//! instead; that is fail-closed. +//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it +//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it). +//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on +//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the +//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the +//! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; -use pdfcraft_cos::{Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; @@ -53,18 +54,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the - // filters before it, so a scan stored as Group 4 is cleared like any other image. + // A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`, + // after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other + // image. let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice()); let readable = |f: &Object| f.as_name().is_some_and(supported); - let (ok, is_fax) = match s.dict.get(b"Filter") { - None => (true, false), - Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)), + let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") { + None => (true, false, false), + Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), Some(Object::Array(a)) => match a.split_last() { - Some((last, before)) if fax(last) => (before.iter().all(readable), true), - _ => (a.iter().all(readable), false), + Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)), + _ => (a.iter().all(readable), false, false), }, - _ => (false, false), + _ => (false, false, false), }; if !ok { return None; @@ -83,7 +86,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let (w, h) = (w as usize, h as usize); let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) }; - if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) { return None; } // Every size comes from the file: multiply checked, so an overflowing product refuses the image @@ -105,7 +109,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? }; // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` - // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels). + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels), + // and refuses a JBIG2 page of another size than the image: the renderer draws the whole page + // into the image, so the rows cleared wouldn't be the ones a mark covers on screen. let mut data = doc.decoded_image_within(s, allowance).ok()?; if data.len() < bytes { return None; @@ -160,5 +166,74 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] dict.remove(b"Length"); dict.remove(b"DecodeParms"); dict.remove(b"Alternates"); - Some(Some(Stream::flate(dict, &data))) + Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) +} + +/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit +/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT +/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already +/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's +/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's +/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. +fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { + let flate = Stream::flate(dict.clone(), data); + let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; + let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }); + let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }); + [g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best }) +} + +/// The bytes stream `s` takes in a file: its data and its dictionary as written. +fn stored_len(s: &Stream) -> usize { + let mut dict = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); + dict.len().saturating_add(s.raw.len()) +} + +/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, +/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest +/// (`smallest`), its other entries kept. `None` when it can't +/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than +/// the image, or not a one-bit image of one component). Redaction does this to every image that +/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn +/// under the area too. +pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { + let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); + if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + let (w, h) = (w as usize, h as usize); + let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { + return None; + } + let bytes = w.div_ceil(8).checked_mul(h)?; + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; + let mut data = doc.decoded_image_within(s, allowance).ok()?; + if data.len() < bytes { + return None; + } + data.truncate(bytes); + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. + dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); + Some(smallest(dict, &data, w, h, true, true)) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..fad55c431f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -70,6 +70,12 @@ pub enum RedactError { /// could not be shown complete. #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] TooDeep, + /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again + /// without them, so the shapes they hold, those of marks under the area included, would stay. + #[error( + "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" + )] + SharedJbig2Globals(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -132,6 +138,9 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, + /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the + /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. + pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, pub forms_rewritten: usize, @@ -509,6 +518,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result Result { + let mut globals = HashSet::new(); + let mut seen = HashSet::new(); + let mut stack: Vec = doomed.to_vec(); + while let Some(r) = stack.pop() { + if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { + continue; + } + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + if let Some(g) = doc.jbig2_globals_ref(s) { + globals.insert(g); + } + // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). + let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); + let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); + if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { + stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); + } + if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { + stack.push(m); + } + } + if globals.is_empty() { + return Ok(0); + } + let sharing: Vec = doc + .scan_objects() + .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) + .map(|(r, _)| r) + .collect(); + let doomed: HashSet = doomed.iter().copied().collect(); + let (mut restored, mut stuck) = (0, 0); + for r in sharing { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + match image::without_globals(doc, s) { + Some(new) => { + doc.set(r, Object::Stream(new)); + restored += usize::from(!doomed.contains(&r)); + } + None if doomed.contains(&r) => {} + None => stuck += 1, + } + } + if stuck > 0 { + return Err(RedactError::SharedJbig2Globals(stuck)); + } + Ok(restored) +} + /// What applying the marks collects across pages. struct Applying { report: Report, diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 125f00fc5c..74b8407056 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -519,13 +519,19 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { ]) } -/// The samples of the image a page's (possibly inherited) resources name `name`. +/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through +/// its image codec too (`Document::decoded_image`). fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { + named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok()) +} + +/// The image a page's (possibly inherited) resources name `name`. +fn named_stream(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(), + Object::Stream(s) => Some(s.clone()), _ => None, } } @@ -736,6 +742,506 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() { assert_eq!((report.images_cleared, report.images_removed), (1, 0)); } +/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included, +/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels +/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen +/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding. +/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a +/// search that one cleared byte would blind isn't all that holds this. +#[test] +fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { + const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E]; + const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F]; + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + let (w, h) = (512usize, 64usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED); + bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT); + for top in (16..h - 8).step_by(10) { + for x in (4..w - 8).step_by(8) { + for (dy, row) in LETTER.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let holds = |doc: &Document, pixels: &[u8]| { + doc.scan_objects().any(|(_, object)| match &*object { + Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), + _ => false, + }) + }; + // Columns 0 to 99 of rows 0 to 7. + let mut cleared = bits.clone(); + for row in cleared.chunks_mut(stride).take(8) { + row[..12].fill(0); + row[12] &= 0x0F; + } + let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests"); + for coded in [symbols, generic] { + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"); + // Half a point a pixel, its top row at y 132. + let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]); + assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found"); + mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file"); + assert!(holds(&saved, &KEPT), "the pixels beside them stay"); + assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed"); + } +} + +/// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it +/// needn't read, holding `secret`; its 11-byte header gives the length of the data after it. +fn jbig2_globals(secret: &[u8]) -> Vec { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(4 + secret.len()).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.extend_from_slice(secret); + g +} + +/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, +/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by +/// reference, behind another filter with its globals at its own place, or as a mask, its decode +/// array inverting it or not. +#[test] +fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let white = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_jbig2_generic(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = white.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let globals = stream("", jbig2_globals(b"shared")); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode"), white.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms 8 0 R"), + white.clone(), + vec![b"<< /JBIG2Globals 9 0 R >>".to_vec(), globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + hex, + vec![globals], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /JBIG2Decode"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /JBIG2Decode"), white, vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// A JBIG2 image is removed whole when its covered pixels can't be cleared for certain: a JBIG2 +/// page taller than the image (the renderer draws the whole page into the image, so the rows cleared +/// wouldn't be the ones shown) +/// or shorter, or of another width; globals that can't be read; data cut short; an image of more +/// than one bit or component, which JBIG2 doesn't code, even when its rows take as many bytes; and +/// a page far past the image's raster, which isn't decoded. +#[test] +fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() { + let white = |rows: u32| pdfcraft_filters::encode_jbig2_generic(&vec![0xFF; 2 * rows as usize], 16, rows).unwrap(); + // The page information (the first segment's data, after its 11-byte header) claiming 20,000 + // rows of 16. + let mut tall = white(2); + tall[15..19].copy_from_slice(&20_000u32.to_be_bytes()); + let image = |size: &str, more: &str| { + format!("/Type /XObject /Subtype /Image {size} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {more}") + }; + let size = "/Width 16 /Height 2"; + for (dict, data, extra) in [ + (image(size, ""), white(3), vec![]), + (image("/Width 16 /Height 3", ""), white(2), vec![]), + (image("/Width 15 /Height 2", ""), white(2), vec![]), + (image(size, "/DecodeParms << /JBIG2Globals 8 0 R >>"), white(2), vec![stream("", b"not JBIG2")]), + (image(size, ""), white(2)[..20].to_vec(), vec![]), + (image(size, "").replace("/BitsPerComponent 1", "/BitsPerComponent 8"), white(2), vec![]), + (image(size, "").replace("/DeviceGray", "/DeviceRGB"), white(2), vec![]), + // Two pixels of RGB at a bit take a byte a row, as JBIG2's rows do. + ( + image("/Width 2 /Height 2", "").replace("/DeviceGray", "/DeviceRGB"), + pdfcraft_filters::encode_jbig2_generic(&[0xFF; 2], 2, 2).unwrap(), + vec![], + ), + (image(size, ""), tall, vec![]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is +/// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals, +/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller +/// than Flate, and refers to no globals. +#[test] +fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let original = coded.clone(); + assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded"); + let dict = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>" + ); + // Half a point a pixel, its top row at y 120. + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, coded), stream("", jbig2_globals(b"LEAK_GLOBALS"))], + ); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); + let mut cleared = bits.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_no_secret(&doc, b"LEAK_GLOBALS"); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals"); + let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap()); + assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len()); + for (r, object) in saved.scan_objects() { + if let Object::Stream(s) = &*object { + assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}"); + } + } +} + +/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges +/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above. +fn ragged_scan(w: usize, h: usize) -> Vec { + let stride = w / 8; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut bits = vec![0xFFu8; stride * h]; + for top in (3..h - 14).step_by(20) { + let mut x = 4; + while x + 70 < w { + let len = 20 + (next() % 40) as usize; + for y in top..top + 14 { + let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize); + for xx in left..right { + bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += len + 6 + (next() % 6) as usize; + } + } + bits +} + +/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when +/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it +/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. +#[test] +fn a_cleared_black_and_white_scan_is_stored_as_group_4() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap(); + assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here"); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + for (dict, data, filter) in [ + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&bits), + &b"CCITTFaxDecode"[..], + ), + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&grey), + &b"FlateDecode"[..], + ), + ] { + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + // The left quarter of the top half. + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}"); + // Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199. + let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() }; + for y in 0..100 { + for x in 0..200 { + if filter == b"FlateDecode" { + expected[y * w + x] = 0; + } else { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}"); + } +} + +/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one, +/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows +/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear. +#[test] +fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { + let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1; + for (w, h) in [(3usize, 2usize), (wide, 2)] { + let stride = w.div_ceil(8); + assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}"); + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))], + ); + // The top row's left half. + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}"); + let samples = named_image(&saved, 0, b"PCRedacted1").unwrap(); + let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept"); + assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept"); + } +} + +/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white +/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the +/// first rows' worth of bytes, would look far smaller while losing the picture. +#[test] +fn a_cleared_image_of_more_than_one_bit_stays_flate() { + let (w, h) = (256usize, 256usize); + let mut data = vec![0xFF; w * h]; + let mut state = 0x1234_5678u32; + for b in data.iter_mut().skip(w * h / 8) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *b = state.to_le_bytes()[0]; + } + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]); + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept"); +} + +/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save, +/// clears it again rather than removing it whole, keeps what the first cleared, and stores it +/// smaller than Flate again. +#[test] +fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray"); + for (filter, data, wanted) in [ + ("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]), + ("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]), + ] { + let dict = format!("{image} /Filter {filter}"); + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}"); + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}"); + mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], ""); + let report = apply(&mut saved, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed"); + let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap(); + // Rows 0 to 99 of the left quarter, then 100 to 199 of the right. + let mut expected = bits.clone(); + for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] { + for y in rows { + for x in columns.clone() { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let names: Vec> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect(); + let stored = names.iter().find_map(|n| named_image(&again, 0, n)); + assert_eq!(stored, Some(expected), "{filter}"); + } +} + +/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the +/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to, +/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2). +fn jbig2_segments(mut coded: &[u8]) -> Vec> { + let mut segments = Vec::new(); + while !coded.is_empty() { + let refers = usize::from(coded[5] >> 5); + let header = 4 + 1 + 1 + refers + 1 + 4; + let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize; + segments.push(coded[..header + length].to_vec()); + coded = &coded[header + length..]; + } + segments +} + +/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as +/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a +/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page +/// is redacted the second page's image is stored again without the globals, and neither the +/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel +/// outside the area. +#[test] +fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| { + for (dy, row) in shape.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8)); + } + } + }; + // The letter over and over lower down, and the signature once, top left, under the mark. + let mut first = vec![0xFFu8; stride * h]; + for top in [14usize, 24, 32] { + for x in (3..w - 6).step_by(7) { + draw(&mut first, &LETTER, top, x); + } + } + draw(&mut first, &SIGNATURE, 2, 10); + // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate. + let second = ragged_scan(w, h); + // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the + // second page's segments are numbered past it. + let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); + assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary"); + let mut dictionary = segments.remove(1); + dictionary[6] = 0; + let page_one: Vec = segments.concat(); + let page_two: Vec = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap()) + .into_iter() + .enumerate() + .flat_map(|(k, mut s)| { + s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes()); + s + }) + .collect(); + let image = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>" + ); + // Half a point a pixel, the image's top row at y 120. + let page = |content: u32, im: u32| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>") + .into_bytes() + }; + let build = |second_image: &str| { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + page(5, 7), + page(6, 8), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream(&image, page_one.clone()), + stream(second_image, page_two.clone()), + stream("", dictionary.clone()), + ]) + }; + let mut doc = build(&image); + // A page's picture as it is drawn, JBIG2 read with its globals. + let picture = |doc: &Document, page: usize, name: &[u8]| { + 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) => doc.decoded_image(s).ok(), + _ => None, + } + }; + assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals"); + assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone())); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1)); + let mut cleared = first.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = reopen(&doc); + assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture"); + // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate. + let restored = named_stream(&saved, 1, b"Im").unwrap(); + let flate = pdfcraft_filters::encode_flate(&second); + assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len()); + // The dictionary's data (after its 11-byte header) codes the signature's bitmap. + let coded = &dictionary[11..]; + for (r, object) in saved.scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals"); + let data = match &*object { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }; + assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}"); + } + // The second page's image can't be read for certain (its JBIG2 page is shorter than the + // image), so it can't be stored without the globals: the redaction is refused. + let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + /// 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. @@ -2377,7 +2883,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) { pdfcraft_cos::serialize(&object, &mut bytes); assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { - let data = s.decoded().unwrap(); + // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document. + let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index df6896b980..67b9c86906 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -802,6 +802,472 @@ fn indexed_palettes_over_lab_keep_their_colours() { } assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}"); } + +/// A Pattern space's underlying space is any other, named or by reference as well as a direct array +/// (ISO 32000-2 §8.7.3.3). It was skipped when it was a name or a reference, so an uncoloured tiling +/// pattern took its components as RGB: a CMYK cyan drew red, and a grey of 0.5 dark red. Each is +/// compared with the same colour painted without the pattern. +#[test] +fn an_uncoloured_pattern_uses_its_underlying_space_in_every_form() { + use super::*; + let render = |space: &str, content: &str| -> [u8; 4] { + let pattern = "0 0 10 10 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 200 200] /Resources << /ColorSpace << /C {space} >> /Pattern << /P 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream\n{pattern}\nendstream endobj\n\ + 6 0 obj /DeviceCMYK endobj\n\ + 7 0 obj /DeviceGray endobj\n\ + 8 0 obj [/Indexed /DeviceRGB 1 <00FF00FF0000>] endobj\n\ + 9 0 obj [/Pattern /DeviceCMYK] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + pattern.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{space}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let fill = "0 0 200 200 re f"; + let cyan = render("/DeviceCMYK", &format!("/DeviceCMYK cs 1 0 0 0 sc {fill}")); + let grey = render("/DeviceGray", &format!("/DeviceGray cs 0.5 sc {fill}")); + let green = render("8 0 R", &format!("/C cs 0 sc {fill}")); + assert_ne!(cyan[0], 255, "cyan has no red"); + for (space, colour, want) in [ + ("[/Pattern /DeviceCMYK]", "1 0 0 0", cyan), + ("[/Pattern 6 0 R]", "1 0 0 0", cyan), + ("9 0 R", "1 0 0 0", cyan), + ("[/Pattern /DeviceGray]", ".5", grey), + ("[/Pattern 7 0 R]", ".5", grey), + ("[/Pattern 8 0 R]", "0", green), + ("[/Pattern [/Indexed /DeviceRGB 1 <00FF00FF0000>]]", "0", green), + ] { + assert_eq!(render(space, &format!("/C cs {colour} /P scn {fill}")), want, "{space}"); + } +} + +/// A colour space's parts can refer back to the space, now that a Pattern's underlying space is +/// followed as an Indexed space's base always was. A file made so ends as a space that can't be +/// read, and the page draws what it can. +#[test] +fn a_colour_space_that_refers_to_itself_ends() { + use super::*; + let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f"; + for (space, objects) in [ + ("5 0 R", "5 0 obj [/Pattern 5 0 R] endobj"), + ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Pattern 5 0 R] endobj"), + ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Indexed 5 0 R 1 <0011>] endobj"), + ("5 0 R", "5 0 obj [/Indexed 5 0 R 1 <0011>] endobj"), + ] { + 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 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + {objects}\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, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{objects}: {:?}", page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{objects}: what comes before it drew"); + } +} + +/// A chain of colour spaces repeats no object, so what ends a cycle lets it through. Each way one +/// space names another ends, at a depth that leaves room on the stack, and the page draws what it +/// can. +#[test] +fn a_chain_of_colour_spaces_ends() { + use super::*; + const LINKS: u32 = 20_000; + let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f"; + let link: [(&str, &dyn Fn(u32) -> String); 5] = [ + ("[/Pattern 10 0 R]", &|next| format!("[/Pattern {next} 0 R]")), + ("10 0 R", &|next| format!("[/Indexed {next} 0 R 1 <0011>]")), + ("10 0 R", &|next| format!("[/Separation /A {next} 0 R 5 0 R]")), + ("10 0 R", &|next| format!("[/DeviceN [/A] {next} 0 R 5 0 R]")), + ("[/ICCBased 10 0 R]", &|next| format!("<< /N 1 /Alternate [/ICCBased {next} 0 R] /Length 1 >> stream\nx\nendstream")), + ]; + for (space, link) in link { + let objects: String = (0..LINKS).map(|i| format!("{} 0 obj {} endobj\n", 10 + i, link(11 + i))).collect(); + 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 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + {objects}{} 0 obj /DeviceGray endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + 10 + LINKS + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{}: {:?}", link(11), page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", link(11)); + } +} + +/// A file nests colour spaces five levels at most, and the limit is well past that: 31 spaces over +/// a device space are read and draw their colour, and with one more the space isn't read. +#[test] +fn colour_spaces_are_read_to_the_limit_of_their_nesting() { + use super::*; + let content = "/C cs 1 scn 0 0 200 200 re f"; + let green = |levels: u32| { + let objects: String = (0..levels - 1).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect(); + 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 200 200] /Resources << /ColorSpace << /C 10 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\ + {objects}{} 0 obj [/Separation /A /DeviceRGB 6 0 R] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + 10 + levels - 1 + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{levels}: {:?}", page.error); + page.rgba.get(..4) == Some(&[0, 255, 0, 255][..]) + }; + assert!(green(1), "a space on its own"); + assert!(green(5), "as deep as a legal file goes"); + assert!(green(31), "the deepest that is read"); + assert!(!green(32), "one past it"); +} + +/// A page with the colour space resources `spaces` and the other objects `objects`, drawn with +/// `content`. Objects 5 and 6 are tint transforms: the tint itself, and paper to green. +fn page_with_colour_spaces(spaces: &str, objects: &str, content: &str) -> super::RenderedPage { + use super::*; + 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 200 200] /Resources << /ColorSpace << {spaces} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\ + {objects}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }) +} + +/// What an ICC profile reads as is kept. If what it falls back to were kept with it, a page +/// selecting the last of a chain first would build each space on the one before, and no read +/// would be deep: 20,000 levels past the limit, for whatever walks the space to paint with it. +#[test] +fn a_chain_built_on_spaces_already_read_ends() { + const LINKS: u32 = 20_000; + let selected: String = (0..LINKS).rev().map(|i| format!("/C{i} cs\n")).collect(); + let content = format!("1 0 0 rg 0 0 100 100 re f\n{selected}0 sc 100 100 100 100 re f"); + let spaces: String = (0..LINKS).map(|i| format!("/C{i} [/ICCBased {} 0 R] ", 10 + i)).collect(); + let around: [&dyn Fn(&str) -> String; 4] = [ + &|next| format!("[/Pattern {next}]"), + &|next| format!("[/Indexed {next} 1 <0011>]"), + &|next| format!("[/Separation /A {next} 5 0 R]"), + &|next| format!("[/DeviceN [/A] {next} 5 0 R]"), + ]; + for around in around { + let mut objects: String = (0..LINKS) + .map(|i| { + let alternate = around(&format!("[/ICCBased {} 0 R]", 11 + i)); + format!("{} 0 obj << /N 1 /Alternate {alternate} /Length 1 >> stream\nx\nendstream endobj\n", 10 + i) + }) + .collect(); + objects.push_str(&format!("{} 0 obj << /N 1 /Length 1 >> stream\nx\nendstream endobj\n", 10 + LINKS)); + let page = page_with_colour_spaces(&spaces, &objects, &content); + assert!(page.error.is_none(), "{}: {:?}", around("next"), page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", around("next")); + } +} + +/// A profile met at the limit can't read its alternate there, which says nothing of a read nearer +/// the top: the space kept from the first read isn't what the second gets. +#[test] +fn a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top() { + let chain: String = (0..31).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect(); + let objects = format!( + "{chain}41 0 obj [/ICCBased 7 0 R] endobj\n\ + 7 0 obj << /N 1 /Alternate [/Separation /A /DeviceRGB 6 0 R] /Length 1 >> stream\nx\nendstream endobj\n" + ); + let page = page_with_colour_spaces("/Deep 10 0 R /Near 41 0 R", &objects, "/Deep cs /Near cs 1 scn 0 0 200 200 re f"); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(page.rgba.get(..4), Some(&[0, 255, 0, 255][..])); +} + +/// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for +/// the whole pair, found no pair there, and ended the array, so the image didn't draw at all. +#[test] +fn a_decode_array_with_references_in_it_is_read() { + use super::*; + let render = |width_of: &str, decode: &str, space: &str, data: &str| -> [u8; 4] { + let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q"; + 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 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width {width_of} /Height 1 /BitsPerComponent 8 /ColorSpace {space} /Decode {decode} /Filter /ASCIIHexDecode /Length {} >> stream\n{data}\nendstream endobj\n\ + 6 0 obj 1 endobj\n\ + 7 0 obj 0 endobj\n\ + 8 0 obj 8 0 R endobj\n\ + 9 0 obj 10 0 R endobj\n\ + 10 0 obj 9 0 R endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + data.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{decode}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let grey = render("1", "[1 0]", "/DeviceGray", "40>"); + assert_eq!(grey, [191, 191, 191, 255], "the direct form inverts the sample"); + for decode in ["[6 0 R 0]", "[1 7 0 R]", "[6 0 R 7 0 R]"] { + assert_eq!(render("1", decode, "/DeviceGray", "40>"), grey, "{decode}"); + } + let rgb = render("1", "[1 0 1 0 1 0]", "/DeviceRGB", "204060>"); + for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] { + assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}"); + } + // A reference that only leads back to itself holds no number, and reads as one that names + // nothing does. + let missing = render("1", "[99 0 R 0]", "/DeviceGray", "40>"); + for decode in ["[8 0 R 0]", "[1 8 0 R]", "[9 0 R 0]", "[1 9 0 R]", "[8 0 R 9 0 R]"] { + assert_eq!(render("1", decode, "/DeviceGray", "40>"), missing, "{decode}"); + } +} + +/// An object that holds only a reference stands for what that names, stored on its own or in an +/// object stream. Read as a number it was the reference's own object number: here 8 names 7, 7 +/// names 6 and 6 holds 1, and that read as 7, which drew white. A chain past the limit, and so a +/// cycle, reads as a missing object does. +#[test] +fn a_reference_to_a_reference_is_what_it_names() { + use super::*; + let file = |decode: &str, objects: &str| -> Vec { + let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q"; + 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 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 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 /BitsPerComponent 8 /ColorSpace /DeviceGray /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\n40>\nendstream endobj\n\ + {objects}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() + }; + let draw = |bytes: Vec| -> [u8; 4] { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let grey = draw(file("[1 0]", "")); + assert_eq!(grey, [191, 191, 191, 255]); + + let chain = "6 0 obj 1 endobj 7 0 obj 6 0 R endobj 8 0 obj 7 0 R endobj"; + assert_eq!(draw(file("[8 0 R 0]", chain)), grey, "stored on their own"); + let doc = pdfcraft_cos::Document::open(Arc::new(file("[8 0 R 0]", chain))).unwrap(); + let packed = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + assert!(packed.windows(7).any(|w| w == b"/ObjStm"), "the save packs them"); + assert_eq!(draw(packed), grey, "stored in an object stream"); + assert_eq!(draw(file("9 0 R", "9 0 obj 10 0 R endobj 10 0 obj [1 0] endobj")), grey, "not only numbers"); + let commented = "6 0 obj 1 endobj 7 0 obj 6 %c\n0 R endobj 8 0 obj 7 0 %c\nR endobj"; + assert_eq!(draw(file("[8 %c\n0 R 0]", commented)), grey, "a comment counts as white space"); + + let links = |n: usize| -> String { + let links: String = (0..n).map(|i| format!("{} 0 obj {} 0 R endobj\n", 10 + i, 11 + i)).collect(); + format!("{links}{} 0 obj 1 endobj", 10 + n) + }; + let missing = draw(file("[99 0 R 0]", "")); + assert_ne!(missing, grey); + assert_eq!(draw(file("[10 0 R 0]", &links(32))), grey, "32 references are followed"); + assert_eq!(draw(file("[10 0 R 0]", &links(33))), missing, "33 aren't"); + assert_eq!(draw(file("[6 0 R 0]", "6 0 obj 6 0 R endobj")), missing, "nor is a cycle"); +} + +/// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they +/// lost a reference at the start of a pair the same way, and the array with it. Each form is compared +/// with the direct one, which differs from the identity so the array is shown to count. +#[test] +fn a_functions_arrays_can_start_with_a_reference() { + use super::*; + let render = |function: String| -> [u8; 4] { + let content = "/C cs 0.25 sc 0 0 200 200 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 200 200] /Resources << /ColorSpace << /C [/Separation /X /DeviceRGB 9 0 R] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj 1 endobj\n6 0 obj 0 endobj\n\ + 7 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 0 0] /N 1 >> endobj\n\ + 8 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 1 0] /C1 [0 0 1] /N 1 >> endobj\n\ + 9 0 obj {function} 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, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{function}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let sampled = |encode: &str| { + format!( + "<< /FunctionType 0 /Size [2] /BitsPerSample 8 /Domain [0 1] /Range [0 1 0 1 0 1] /Encode {encode} /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF8040>\nendstream" + ) + }; + let stitched = |encode: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [7 0 R 8 0 R] /Bounds [.5] /Encode {encode} >>"); + assert_ne!(render(sampled("[1 0]")), render(sampled("[0 1]")), "/Encode counts"); + assert_eq!(render(sampled("[5 0 R 0]")), render(sampled("[1 0]")), "a sampled function"); + assert_ne!(render(stitched("[1 0 0 1]")), render(stitched("[0 1 0 1]")), "/Encode counts"); + assert_eq!(render(stitched("[5 0 R 0 0 1]")), render(stitched("[1 0 0 1]")), "a stitching function"); +} + +/// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can +/// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it +/// as a stream used up the reference, so the space couldn't be read and drew in grey. +#[test] +fn an_indexed_lookup_table_is_read_in_every_legal_form() { + use super::*; + let render = |space: &str, content: &str, extra: &str| -> [u8; 4] { + 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 200 200] /Resources << /ColorSpace << /I {space} >> /XObject << /Im 9 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj <000000FF0000> endobj\n\ + 6 0 obj << /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF0000>\nendstream endobj\n\ + 7 0 obj /DeviceRGB endobj\n\ + 8 0 obj 1 endobj\n\ + {extra}\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, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{space}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let paint = "/I cs 1 sc 0 0 200 200 re f"; + // One byte of index 1, as an image in the same space: the image path reads it too. + let image = "9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 5 0 R] /Filter /ASCIIHexDecode /Length 3 >> stream\n01>\nendstream endobj"; + for space in [ + "[/Indexed /DeviceRGB 1 <000000FF0000>]", + "[/Indexed /DeviceRGB 1 5 0 R]", + "[/Indexed /DeviceRGB 1 6 0 R]", + "[/Indexed 7 0 R 8 0 R 5 0 R]", + "[/I /DeviceRGB 1 5 0 R]", + ] { + assert_eq!(render(space, paint, ""), [255, 0, 0, 255], "{space}"); + } + let indirect = "10 0 obj [/Indexed /DeviceRGB 1 5 0 R] endobj"; + assert_eq!(render("10 0 R", paint, indirect), [255, 0, 0, 255], "the whole space by reference"); + assert_eq!(render("/DeviceRGB", "q 200 0 0 200 0 0 cm /Im Do Q", image), [255, 0, 0, 255], "an image's table by reference"); + // What's neither table form leaves the space unreadable, as a missing table does. + for space in ["[/Indexed /DeviceRGB 1 8 0 R]", "[/Indexed /DeviceRGB 1 99 0 R]", "[/Indexed /DeviceRGB 1]"] { + let _ = render(space, paint, ""); + } + // So does a table that only leads back to itself, and the read of it ends. + let cycle = "11 0 obj 11 0 R endobj 12 0 obj 13 0 R endobj 13 0 obj 12 0 R endobj"; + for space in ["[/Indexed /DeviceRGB 1 11 0 R]", "[/Indexed /DeviceRGB 1 12 0 R]"] { + let _ = render(space, paint, cycle); + } +} #[test] fn watchdog_skips_a_stuck_page_and_keeps_rendering() { use super::*; @@ -3527,6 +3993,872 @@ fn jbig2_regions_of_billions_of_pixels_are_refused() { assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } +/// One embedded JBIG2 segment (ISO 14492 §7.2) of page 1. More than four references take the long +/// form, whose retention flags are left clear. +fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + if refs.len() <= 4 { + out.push(u8::try_from(refs.len()).unwrap() << 5); + } else { + out.extend_from_slice(&(0xE000_0000u32 | u32::try_from(refs.len()).unwrap()).to_be_bytes()); + out.extend(std::iter::repeat_n(0u8, (refs.len() + 1).div_ceil(8))); + } + out.extend_from_slice(refs); + out.push(1); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); +} + +/// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if +/// it decodes, draws black whatever its other segments hold. +fn black_jbig2_page() -> Vec { + let mut page = Vec::new(); + page.extend_from_slice(&16u32.to_be_bytes()); + page.extend_from_slice(&2u32.to_be_bytes()); + page.extend_from_slice(&[0; 8]); // resolution + page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 0, 48, &[], &page); + stream +} + +/// A region's information field, at the page's corner. +fn jbig2_region_info(width: u32, height: u32) -> Vec { + let mut region = Vec::new(); + region.extend_from_slice(&width.to_be_bytes()); + region.extend_from_slice(&height.to_be_bytes()); + region.extend_from_slice(&[0; 9]); // x, y, combination operator + region +} + +/// Whether a 16 × 2 JBIG2 image decodes: drawn at 5..25 both ways, it draws black, and refused it +/// leaves the page white. The red square beside it shows the rest of the page drew either way. +fn jbig2_image_draws(stream: &[u8]) -> bool { + jbig2_image_draws_in(stream, "/DeviceGray") +} + +/// [`jbig2_image_draws`], for an image in colour space `space`. +fn jbig2_image_draws_in(stream: &[u8], space: &str) -> bool { + jbig2_image_draws_with(stream, space, "", b"") +} + +/// [`jbig2_image_draws_in`], with entries for the image's dictionary and more objects, from 6 up. +fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[u8]) -> bool { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 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".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace {space} {entries} /Filter /JBIG2Decode /Length {} >> stream\n", stream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); + // Inside the image: top-down rows 15..35. + match &page.rgba[((25 * 40 + 15) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither the image nor the page: {other:?}"), + } +} + +/// Bits written from the top of each byte, as JBIG2's Huffman-coded data is. +#[derive(Default)] +struct Bits { + bytes: Vec, + used: u32, +} + +impl Bits { + fn put(&mut self, value: u32, count: u32) { + for i in (0..count).rev() { + if self.used.is_multiple_of(8) { + self.bytes.push(0); + } + if (value >> i) & 1 != 0 { + *self.bytes.last_mut().unwrap() |= 0x80 >> (self.used % 8); + } + self.used += 1; + } + } + + fn align(&mut self) { + self.used = self.used.div_ceil(8) * 8; + } + + /// `value` in table B.1 (ISO 14492 Annex B). + fn table_b1(&mut self, value: u32) { + match value { + 0..=15 => { + self.put(0, 1); + self.put(value, 4); + } + 16..=271 => { + self.put(0b10, 2); + self.put(value - 16, 8); + } + 272..=65807 => { + self.put(0b110, 3); + self.put(value - 272, 16); + } + _ => { + self.put(0b111, 3); + self.put(value - 65808, 32); + } + } + } +} + +/// A Huffman-coded symbol dictionary of `symbols` symbols of width 0 and height 1, exported or not: +/// each holds no pixels, and costs a bitmap's header all the same. +fn zero_width_jbig2_dictionary(symbols: u32, exported: bool) -> Vec { + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&(if exported { symbols } else { 0 }).to_be_bytes()); + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + let mut bits = Bits::default(); + bits.put(0, 1); // height class delta 1 (B.4) + for _ in 0..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + if exported { + bits.table_b1(0); // the first export run isn't exported + } + bits.table_b1(symbols); + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A Huffman-coded symbol dictionary of `symbols` symbols of width 1 and no rows, none exported. +/// Only table B.5 has a negative height class delta, so it gets to no rows from one. +fn no_row_jbig2_dictionary(symbols: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0, 1); // height class delta 1 (B.5), a class of no symbols + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254 + bits.put(254, 8); + bits.put(0b10, 2); // width delta 1 (B.2) + for _ in 1..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A Huffman-coded symbol dictionary that climbs to `height`, stays about there for `classes` +/// classes of no symbols, each with a byte of MMR bitmap, and drops to one row for its one symbol, +/// not exported. +fn empty_class_jbig2_dictionary(classes: u32, height: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0b111110, 6); // height class delta, the upper line of table B.5 + bits.put(height - 76, 32); + let mut at = height; + for class in 0..classes { + // Table B.5 has no delta of 0, so it goes down one and up one. + if class % 2 == 1 { + bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254 + bits.put(254, 8); + at -= 1; + } else if class > 0 { + bits.put(0, 1); // height class delta 1 (B.5) + at += 1; + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(1, 5); // BMSIZE 1 (B.1) + bits.align(); + bits.put(0xFF, 8); + } + bits.put(0b1111111, 7); // height class delta, the lower line of table B.5 + bits.put(at - 1 - 256, 32); + bits.put(0b10, 2); // width delta 1 (B.2) + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.put(0, 8); // its row + bits.table_b1(1); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A Huffman-coded symbol dictionary of `symbols` white symbols of width 1 and `height` rows, none +/// exported, for `height` from 76 up. +fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(height - 76, 32); + bits.put(0b10, 2); // width delta 1 (B.2) + for _ in 1..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + bits.put(0b111111, 6); // end of the class (B.2) + let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(rows); // BMSIZE (B.1) + bits.align(); + for _ in 0..rows { + bits.put(0xFF, 8); + } + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// An intermediate generic region, white, every row typical: it decodes in a few bytes however +/// large it is. +fn jbig2_white_region(width: u32, height: u32) -> Vec { + let mut region = jbig2_region_info(width, height); + region.push(0x08); // arithmetic coding, template 0, typical prediction + region.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels + // Every row typical, the first white: pdfcraft-filters' coding of a white 16,384 square. + region.extend_from_slice(&[0xb6, 0xc2, 0x1f, 0xff, 0x7f, 0xff, 0xac]); + region +} + +/// A pattern dictionary of `patterns` patterns of width 1 and `height` rows, with nothing coded. +fn jbig2_pattern_dictionary(height: u8, patterns: u32) -> Vec { + let mut dictionary = vec![0, 1, height]; + dictionary.extend_from_slice(&(patterns - 1).to_be_bytes()); + dictionary +} + +/// A 1 × 1 halftone region over a grid of `width` × `height` cells, all at the region's corner, +/// coded by MMR or arithmetically. +fn jbig2_halftone(width: u32, height: u32, mmr: bool) -> Vec { + jbig2_halftone_of(1, width, height, mmr) +} + +/// [`jbig2_halftone`] for a square region of `side` pixels. +fn jbig2_halftone_of(side: u32, width: u32, height: u32, mmr: bool) -> Vec { + let mut halftone = jbig2_region_info(side, side); + halftone.push(u8::from(mmr)); // template 0 + halftone.extend_from_slice(&width.to_be_bytes()); + halftone.extend_from_slice(&height.to_be_bytes()); + halftone.extend_from_slice(&[0; 12]); // grid offset and vector + halftone +} + +/// Each JBIG2 segment's bitmaps are bounded, but nothing bounded what a page's segments hold for +/// later ones: a stream of a few dozen bytes an intermediate region (16,384 samples a side, every +/// row typical) held 33 MB each, so a few kilobytes could exhaust memory. Vendored hayro-jbig2 +/// patch: what regions, dictionaries and tables hold is at most 64 MiB (`MAX_RETAINED_WORDS`), two +/// such squares, which are exactly the bound. +#[test] +fn jbig2_regions_held_past_a_pages_bound_are_refused() { + let stream = |regions: u32| { + let mut stream = black_jbig2_page(); + for number in 1..=regions { + jbig2_segment(&mut stream, number, 36, &[], &jbig2_white_region(16384, 16384)); + } + stream + }; + assert!(jbig2_image_draws(&stream(2)), "two such squares fit"); + assert!(!jbig2_image_draws(&stream(3)), "a third doesn't"); +} + +/// A symbol of width 0 holds no pixels, and still costs a bitmap's header: 5 KB of dictionaries of +/// them held 781 MB, with the bound counting pixels only. +#[test] +fn jbig2_symbols_without_pixels_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut stream = black_jbig2_page(); + let dictionary = zero_width_jbig2_dictionary(32767, true); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 0, &[], &dictionary); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one dictionary fits"); + assert!(!jbig2_image_draws(&stream(44)), "44 × 32,767 headers are past the bound"); +} + +/// A symbol dictionary may retain its coding contexts for a later one, 73,728 bytes each time, and +/// 1.1 MB of dictionaries with no symbols, 103 KB behind Flate, held 2.89 GB. +#[test] +fn jbig2_retained_contexts_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut dictionary = vec![0x02, 0x00]; // arithmetic coding, contexts retained + dictionary.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut stream = black_jbig2_page(); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 0, &[], &dictionary); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one dictionary fits"); + assert!(!jbig2_image_draws(&stream(1000)), "a thousand don't"); +} + +/// Huffman tables are held for later segments too, each taking some 586 bytes however small it +/// is, so 200,000 of them are past the bound, and they were held to it at 4 times that. +#[test] +fn jbig2_huffman_tables_count_towards_a_pages_bound() { + let stream = |tables: u32| { + // One-bit prefix and range lengths, values 0 to 1: a table line, then the lower range. + let mut table = vec![0]; + table.extend_from_slice(&0i32.to_be_bytes()); + table.extend_from_slice(&1i32.to_be_bytes()); + table.push(0b1010_0000); + let mut stream = black_jbig2_page(); + for number in 1..=tables { + jbig2_segment(&mut stream, number, 53, &[], &table); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one table fits"); + assert!(!jbig2_image_draws(&stream(200_000)), "200,000 don't"); +} + +/// New symbols count as they are decoded, whatever the dictionary exports: rows take a word at +/// least, so symbols of one column held 270 MiB within the bound on a dictionary's pixels, and +/// exporting none of them they were never counted at all. +#[test] +fn jbig2_new_symbols_count_towards_a_pages_bound() { + let stream = |height: u32| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &narrow_jbig2_dictionary(65534, height)); + stream + }; + assert!(jbig2_image_draws(&stream(100)), "65,534 symbols of 100 rows fit"); + assert!(!jbig2_image_draws(&stream(300)), "of 300 rows they don't"); +} + +/// New symbols are held to what the page has left, headers included, not to the whole bound. These +/// two regions leave it 512 words, and the dictionary after them exports nothing, so only that +/// check can refuse it. +#[test] +fn jbig2_new_symbols_are_held_to_what_a_page_has_left() { + let stream = |dictionary: &[u8]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 36, &[], &jbig2_white_region(16384, 16384)); + jbig2_segment(&mut stream, 2, 36, &[], &jbig2_white_region(16384, 16383)); + jbig2_segment(&mut stream, 3, 0, &[], dictionary); + stream + }; + assert!(jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 100))), "100 rows fit"); + assert!(!jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 1000))), "1,000 rows don't"); + assert!(jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(10, false))), "10 headers fit"); + assert!(!jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(100, false))), "100 headers don't"); +} + +/// Pattern dictionaries count towards the bound, with no shifted copies to do it for them: at 44 +/// words a pattern, 6 of these are past it. +#[test] +fn jbig2_pattern_dictionaries_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut stream = black_jbig2_page(); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 65535)); + } + stream + }; + assert!(jbig2_image_draws(&stream(5)), "5 fit"); + assert!(!jbig2_image_draws(&stream(6)), "6 don't"); +} + +/// Shifted copies are only a speed-up, so they are never what gets an image refused: each of these +/// tables is within its own limit, and the fourth won't fit what the page has left, so it is left +/// out, where counting it refused the image. +#[test] +fn jbig2_shifted_patterns_never_refuse_an_image() { + let mut stream = black_jbig2_page(); + for number in 1..=4 { + jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 2048)); + } + assert!(jbig2_image_draws(&stream)); +} + +/// Ordinary globals still reach the decoder, with a filter on them or without: here they hold the +/// page information, so the image draws only if they are read. +#[test] +fn jbig2_globals_are_read() { + let globals = black_jbig2_page(); + let hex: String = globals.iter().map(|b| format!("{b:02x}")).collect(); + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 1, 62, &[], &[]); // an extension, which decoders skip + for (filter, data) in [("", globals.clone()), ("/Filter /ASCIIHexDecode", hex.into_bytes())] { + let mut objects = format!("6 0 obj << {filter} /Length {} >> stream\n", data.len()).into_bytes(); + objects.extend_from_slice(&data); + objects.extend_from_slice(b"\nendstream endobj\n"); + assert!(jbig2_image_draws_with(&stream, "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects), "{filter}"); + } +} + +/// Globals are raw segments, but a `/JBIG2Globals` stream could itself be JBIG2, naming the next +/// such stream, and each read decoded the next: 4,000 of them, here on a test thread's 2 MiB stack, +/// overflowed it, which aborts the process. Vendored hayro-syntax patch: such a stream reads as +/// globals that can't be decoded, so the image decodes without them. +#[test] +fn jbig2_globals_that_are_jbig2_themselves_are_not_decoded() { + let links = 4000; + let mut objects = Vec::new(); + for number in 0..links { + let link = 6 + number; + let next = link + 1; + let parms = if number + 1 < links { format!("/DecodeParms << /JBIG2Globals {next} 0 R >>") } else { String::new() }; + objects.extend_from_slice(format!("{link} 0 obj << /Filter /JBIG2Decode {parms} /Length 0 >> stream\n\nendstream endobj\n").as_bytes()); + } + assert!(jbig2_image_draws_with(&black_jbig2_page(), "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects)); +} + +/// A Huffman-coded symbol dictionary of one white symbol, `side` from 76 up, exported or not. +fn square_jbig2_dictionary(side: u32, exported: bool) -> Vec { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(side - 76, 32); + bits.put(0b111110, 6); // width delta, the upper line of table B.2 + bits.put(side - 75, 32); + bits.put(0b111111, 6); // end of the class (B.2) + let bytes = side.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(bytes); // BMSIZE (B.1) + bits.align(); + for _ in 0..bytes { + bits.put(0xFF, 8); + } + if exported { + bits.table_b1(0); + } + bits.table_b1(1); + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&u32::from(exported).to_be_bytes()); + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A text region of `width` × `height` placing symbol 0 of one, Huffman-coded, `instances` of 0 or 1. +fn jbig2_text_region(width: u32, height: u32, instances: u32) -> Vec { + let mut text = jbig2_region_info(width, height); + text.extend_from_slice(&[0x00, 0x11, 0, 0]); // Huffman, top left; tables B.6, B.8, B.11 + text.extend_from_slice(&instances.to_be_bytes()); + let mut bits = Bits::default(); + for code in 0..35 { + bits.put(u32::from(code == 1), 4); // only run code 1, one bit long + } + bits.put(0, 1); // symbol 0 is one bit long + bits.align(); + bits.put(0, 1); // the first strip's T (B.11) + if instances > 0 { + bits.put(0, 1); // delta T 1 (B.11) + bits.put(0, 9); // first S 0 (B.6) + bits.put(0, 1); // symbol 0 + bits.put(0b01, 2); // end of the strip (B.8) + } + text.extend_from_slice(&bits.bytes); + text +} + +/// A 1 × 1 text region placing one instance of symbol 0 of one, refined, Huffman-coded. +fn jbig2_refined_text_region() -> Vec { + let mut text = jbig2_region_info(1, 1); + text.extend_from_slice(&[0x80, 0x13, 0, 0]); // Huffman, refined by template 1, top left + text.extend_from_slice(&1u32.to_be_bytes()); + let mut bits = Bits::default(); + for code in 0..35 { + bits.put(u32::from(code == 1), 4); // only run code 1, one bit long + } + bits.put(0, 1); // symbol 0 is one bit long + bits.align(); + bits.put(0, 1); // the first strip's T (B.11) + bits.put(0, 1); // delta T 1 (B.11) + bits.put(0, 9); // first S 0 (B.6) + bits.put(0, 1); // symbol 0 + bits.put(1, 1); // refined + bits.put(0, 4); // no change of width, height or offset (B.14) + bits.table_b1(0); // no refinement data (B.1) + bits.align(); + bits.put(0b01, 2); // end of the strip (B.8) + text.extend_from_slice(&bits.bytes); + text +} + +/// A generic refinement region of 1 × 1, with whatever its data decodes to. +fn jbig2_refinement_region() -> Vec { + let mut region = jbig2_region_info(1, 1); + region.push(1); // template 1, no typical prediction + region.extend_from_slice(&[0; 4]); + region +} + +/// A class of no symbols has nothing to cut from its bitmap, and rows are charged by the symbol, so +/// decoding one would be rows for nothing: a thousand classes of 65,000 rows take a second, from a +/// dictionary of 4 KB. It isn't decoded, however many there are, and the symbol after them shows that +/// its bytes are stepped over. +#[test] +fn jbig2_classes_of_no_symbols_decode_no_bitmap() { + let mut stream = black_jbig2_page(); + for number in 0..1_000 { + jbig2_segment(&mut stream, number + 1, 0, &[], &empty_class_jbig2_dictionary(1024, 65_000)); + } + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = done.send(jbig2_image_draws(&stream)); + }); + // This takes milliseconds now and took a quarter of an hour, so a margin this wide can't fail on + // a busy machine. + assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true)); +} + +/// Each item a JBIG2 stream decodes costs its work, and the stream may ask for 2^30: four of the +/// largest pages, some 6 s to 17 s. Each bitmap is bounded on its own, but nothing bounded how many +/// there were, so 1,140 bytes of generic regions took over 40 s, and a 795-byte PDF's one halftone +/// 265 s. Vendored hayro-jbig2 patch: `MAX_WORK`. A row costs 4 pixels, even in a bitmap of no +/// width, and a copy goes by the word and the row, so a tall thin one costs its rows. Here regions +/// of typical rows, cheap to decode though charged like any, spend all but exactly what the item +/// costs, so it draws with that left and is refused with one less, for each item at its own cost. +#[test] +fn jbig2_decoding_work_is_counted_item_by_item() { + struct Item { + what: &'static str, + /// What comes first, and what it costs. + before: Vec<(u8, Vec)>, + spent: u64, + kind: u8, + refs: &'static [u8], + data: Vec, + cost: u64, + } + let stream = |item: &Item, left: u64| { + let mut stream = black_jbig2_page(); + let mut number = 0; + for (kind, data) in &item.before { + number += 1; + jbig2_segment(&mut stream, number, *kind, &[], data); + } + // A square costs 16,384 rows of 16,388, and a single row what it has and 4 more. + let work = (1u64 << 30) - item.spent - left; + let (row, square) = (16388u64, 16384 * 16388u64); + let mut squares = work / square; + let mut rest = work % square; + if rest > 0 && rest < 5 { + squares -= 1; + rest += square; + } + let mut rows = rest / row; + let mut last = rest % row; + if last > 0 && last < 5 { + rows -= 1; + last += row; + } + let regions = std::iter::repeat_n((16384, 16384), usize::try_from(squares).unwrap()) + .chain((rows > 0).then_some((16384, rows))) + .chain((last > 0).then_some((last - 4, 1))); + for (width, height) in regions { + number += 1; + let region = jbig2_white_region(u32::try_from(width).unwrap(), u32::try_from(height).unwrap()); + jbig2_segment(&mut stream, number, 38, &[], ®ion); // immediate generic region + } + jbig2_segment(&mut stream, number + 1, item.kind, item.refs, &item.data); + stream + }; + let symbol = || vec![(0, zero_width_jbig2_dictionary(1, true))]; + let patterns = || vec![(16, jbig2_pattern_dictionary(1, 2))]; + let exporting_none = { + let mut dictionary = vec![0x00, 0x01]; + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut bits = Bits::default(); + bits.table_b1(1); // the first export run, not exported, is the one it refers to + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + let zero = |height: u32| { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(height - 76, 32); + bits.put(0, 1); // width delta 0 (B.2) + bits.put(0b111111, 6); // end of the class (B.2) + bits.table_b1(0); // BMSIZE 0, stored (B.1) + bits.align(); + bits.table_b1(1); // the first export run, not exported, is the symbol + let mut dictionary = vec![0x00, 0x01]; + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + // The cost of a pattern dictionary of two patterns: rows of them side by side, then each taken out. + let two_patterns = |width: u64, height: u64| height * (2 * width + 4) + 2 * height * (width.div_ceil(32) + 1); + let thin_region = |side: u32| { + let mut region = jbig2_white_region(1, 1); + region.splice(0..8, [0u32.to_be_bytes(), side.to_be_bytes()].concat()); + region + }; + let items = vec![ + Item { what: "immediate generic", before: vec![], spent: 0, kind: 38, refs: &[], data: jbig2_white_region(1, 1), cost: 5 }, + Item { what: "intermediate generic", before: vec![], spent: 0, kind: 36, refs: &[], data: jbig2_white_region(1, 1), cost: 5 }, + Item { what: "a region of no width", before: vec![], spent: 0, kind: 38, refs: &[], data: thin_region(1000), cost: 4000 }, + Item { what: "immediate refinement", before: vec![], spent: 0, kind: 42, refs: &[], data: jbig2_refinement_region(), cost: 5 }, + Item { what: "intermediate refinement", before: vec![], spent: 0, kind: 40, refs: &[], data: jbig2_refinement_region(), cost: 5 }, + Item { + what: "pattern dictionary", + before: vec![], + spent: 0, + kind: 16, + refs: &[], + data: jbig2_pattern_dictionary(1, 2), + cost: two_patterns(1, 1), + }, + Item { + what: "tall patterns", + before: vec![], + spent: 0, + kind: 16, + refs: &[], + data: jbig2_pattern_dictionary(32, 2), + cost: two_patterns(1, 32), + }, + // What an item reads counts, at 38 an integer: two classes, five widths and an export run. + Item { what: "symbols of no rows", before: vec![], spent: 0, kind: 0, refs: &[], data: no_row_jbig2_dictionary(5), cost: 304 }, + // Rows cost in a symbol of no pixels too, on top of a class, three widths and an export run. + Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 202 }, + Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4114 }, + Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6834 }, + Item { what: "referred symbols", before: symbol(), spent: 156, kind: 0, refs: &[1], data: exporting_none, cost: 39 }, + // Its symbol, and its one row and word; Huffman-coded, a table line for the symbol. + Item { what: "immediate text", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "intermediate text", before: symbol(), spent: 156, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "a text region's pixels", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, + Item { + what: "an intermediate text region's pixels", + before: symbol(), + spent: 156, + kind: 4, + refs: &[1], + data: jbig2_text_region(32, 32, 0), + cost: 75, + }, + // The region's 13, a copy of the symbol's row, and the instance's 4 integers. + Item { what: "an instance", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 166 }, + // The region's 13, 80 rows of 4 words copied, and the instance's 4 integers. + Item { + what: "an instance of a large symbol", + before: vec![(0, square_jbig2_dictionary(80, true))], + spent: 6872, + kind: 6, + refs: &[1], + data: jbig2_text_region(1, 1, 1), + cost: 485, + }, + // And decoded as well as copied, with the 4 integers that gave its size. + Item { + what: "a refined instance", + before: vec![(0, square_jbig2_dictionary(80, true))], + spent: 6872, + kind: 6, + refs: &[1], + data: jbig2_refined_text_region(), + cost: 7357, + }, + // A bit plane, a cell's copy and the region's. + Item { + what: "immediate halftone", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone(1, 1, false), + cost: 9, + }, + Item { + what: "intermediate halftone", + before: patterns(), + spent: two_patterns(1, 1), + kind: 20, + refs: &[1], + data: jbig2_halftone(1, 1, false), + cost: 9, + }, + // A plane of 2 cells, each a copy of 32 rows of 2 words' cost, and the region's. + Item { + what: "a halftone's cells", + before: vec![(16, jbig2_pattern_dictionary(32, 2))], + spent: two_patterns(1, 32), + kind: 22, + refs: &[1], + data: jbig2_halftone(2, 1, false), + cost: 136, + }, + Item { + what: "a grid of no width", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone(0, 1000, false), + cost: 4002, + }, + Item { + what: "a halftone's pixels", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone_of(32, 1, 1, false), + cost: 71, + }, + ]; + for item in &items { + assert!(jbig2_image_draws(&stream(item, item.cost)), "{}: with its cost left it draws", item.what); + assert!(!jbig2_image_draws(&stream(item, item.cost - 1)), "{}: with one less it's refused", item.what); + } +} + +/// An image's page header can claim any size, and the Indexed path reserves its output from it +/// before the page bitmap is sized: a 743-byte PDF asked for 137 GB, which aborts the process. +/// Vendored hayro-jbig2 patch: a page past the bounds is refused when the stream is opened. +#[test] +fn jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them() { + let indexed = "[/Indexed /DeviceGray 1 <00FF>]"; + assert!(jbig2_image_draws_in(&black_jbig2_page(), indexed), "a page within the bounds"); + let mut page = Vec::new(); + page.extend_from_slice(&(1u32 << 20).to_be_bytes()); + page.extend_from_slice(&(1u32 << 20).to_be_bytes()); + page.extend_from_slice(&[0; 11]); // resolution, flags, striping + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 0, 48, &[], &page); + assert!(!jbig2_image_draws_in(&stream, indexed), "the page draws without it"); +} + +/// One custom Huffman table could grow without bound as it was read, before anything counted it: +/// a line takes 2 bits of input and 8 bytes here, so 250 MiB behind nested Flate, in a 1,297-byte +/// PDF, was killed at 8 GiB. Vendored hayro-jbig2 patch: at most 2^16 lines. +#[test] +fn jbig2_huffman_tables_of_too_many_lines_are_refused() { + let stream = |lines: i32| { + // One-bit prefix and range lengths, all zero: a line per value. + let mut table = vec![0]; + table.extend_from_slice(&0i32.to_be_bytes()); + table.extend_from_slice(&lines.to_be_bytes()); + table.resize(table.len() + usize::try_from(lines).unwrap().div_ceil(4) + 1, 0); + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 53, &[], &table); + stream + }; + assert!(jbig2_image_draws(&stream(1 << 16)), "2^16 lines are allowed"); + assert!(!jbig2_image_draws(&stream((1 << 16) + 1)), "one more isn't"); +} + +/// A stream's segments are all parsed and sorted before the first is decoded: 11 bytes of input took +/// 64 here, so behind nested Flate a 4,765-byte PDF reached 5 GB, and failed only afterwards. +/// Vendored hayro-jbig2 patch: at most 2^20 segments. +#[test] +fn jbig2_streams_of_too_many_segments_are_refused() { + let stream = |segments: u32| { + let mut stream = black_jbig2_page(); + for number in 1..segments { + jbig2_segment(&mut stream, number, 62, &[], &[]); // an extension, which decoders skip + } + stream + }; + assert!(jbig2_image_draws(&stream(1 << 20)), "2^20 segments are allowed"); + assert!(!jbig2_image_draws(&stream((1 << 20) + 1)), "one more isn't"); +} + +/// References take 4 bytes each for 1 of input, a stream's total as much as a segment's. +/// Vendored hayro-jbig2 patch: at most 2^22 in a stream, which is 64 × 65,535 + 64. +#[test] +fn jbig2_streams_of_too_many_references_are_refused() { + let stream = |last: usize| { + let mut stream = black_jbig2_page(); + for number in 1..=64 { + jbig2_segment(&mut stream, number, 62, &vec![0u8; 65535], &[]); + } + jbig2_segment(&mut stream, 65, 62, &vec![0u8; last], &[]); + stream + }; + assert!(jbig2_image_draws(&stream(64)), "2^22 references are allowed"); + assert!(!jbig2_image_draws(&stream(65)), "one more isn't"); +} + +/// One segment may refer to the same dictionary again and again, and collecting its symbols took a +/// pointer each: 65,535 references to a dictionary of 32,767 symbols, in a 78 KB stream, asked for +/// 17 GB. Vendored hayro-jbig2 patch: at most 2^20 symbols per segment, and 32 × 32,767 + 32 is +/// exactly that. All three segment kinds that take symbols are held to it: the text regions, whether +/// immediate or intermediate, and a dictionary that refers to others. +#[test] +fn jbig2_segments_referring_to_too_many_symbols_are_refused() { + let mut text = jbig2_region_info(1, 1); + text.extend_from_slice(&[0, 0]); // arithmetic coding, no refinement + text.extend_from_slice(&[0; 4]); // no instances + // A dictionary of no symbols of its own, exporting none of those it refers to. + let dictionary = |symbols: u32| { + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut bits = Bits::default(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + let stream = |kind: u8, small: usize, data: &[u8]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &zero_width_jbig2_dictionary(32767, true)); + jbig2_segment(&mut stream, 2, 0, &[], &zero_width_jbig2_dictionary(32, true)); + let mut refs = vec![1u8; 32]; + refs.extend(std::iter::repeat_n(2u8, small)); + jbig2_segment(&mut stream, 3, kind, &refs, data); + stream + }; + let limit = 1 << 20; + for (what, kind, allowed, past) in [ + ("immediate text region", 6, text.clone(), text.clone()), + ("intermediate text region", 4, text.clone(), text.clone()), + ("symbol dictionary", 0, dictionary(limit), dictionary(limit + 32)), + ] { + assert!(jbig2_image_draws(&stream(kind, 1, &allowed)), "{what}: 2^20 symbols are allowed"); + assert!(!jbig2_image_draws(&stream(kind, 2, &past)), "{what}: 32 more aren't"); + } +} + +/// A halftone grid takes a 4-byte value per cell while it decodes, so the bound of 2^28 cells let a +/// 98-byte segment take 1 GiB. Vendored hayro-jbig2 patch: at most 2^25 cells, however it's coded. +#[test] +fn jbig2_halftone_grids_past_their_bound_are_refused() { + let stream = |width: u32, height: u32, mmr: bool| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(1, 2)); + jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(width, height, mmr)); + stream + }; + assert!(jbig2_image_draws(&stream(8192, 4096, false)), "2^25 cells are allowed"); + assert!(!jbig2_image_draws(&stream(8193, 4096, false)), "one more column isn't"); + assert!(!jbig2_image_draws(&stream(8193, 4096, true)), "nor when it is MMR-coded"); +} + +/// A pattern dictionary's shifted copies only speed up drawing, and 48 bytes could ask for 539 MB +/// of them, past what a page may hold. Vendored hayro-jbig2 patch: past 16 MiB they are left out, +/// so such a dictionary is drawn the slow way rather than refused. +#[test] +fn jbig2_pattern_dictionaries_too_large_to_shift_still_draw() { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(32, 65535)); + jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(1, 1, false)); + assert!(jbig2_image_draws(&stream)); +} + /// From `cargo xtask fuzz`: one changed byte in a symbol dictionary's arithmetic-coded data /// hung the renderer. Two of its loops can stop advancing: an export run of length 0, and a /// height class whose first width is already the end-of-class marker. Past the end of its @@ -3591,6 +4923,86 @@ fn jbig2_symbol_dictionaries_that_never_advance_terminate() { } } +/// A JBIG2 stream can put a region, a reference or a symbol at the very end of what an `i32` holds, +/// where adding to it overflowed: a panic wherever overflow is checked, a build's own tests included. +/// A region or a symbol out there is off the page, so nothing draws. Vendored hayro-jbig2 patch: the arithmetic wraps, as +/// a release build's did, or in `Bitmap::combine` saturates, which comes to the same. Three of the text +/// streams are arithmetic-coded: one symbol on a strip of -2^31 + 1, with its bottom left as the corner +/// placed, one on a strip of -2^31, and one refined by an offset of `i32::MIN`, which reads nothing of +/// the old symbol and still draws. Two are Huffman-coded, each with its first S at an offset of +/// 0x7FFFFFFF on a table's 32-bit range line, past what an `i32` holds, and are refused further on. +#[test] +fn jbig2_placements_at_the_end_of_an_i32_draw_nothing() { + const FAR_CORNER: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdc3ce0205df55c36bfffac00000002062001010000001f000000100000000c000000000000000004000000000001969747cbe88bffac"; + const STRIP: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda25e46956d3e0d3fffac00000002062001010000001f000000100000000c000000000000000004001000000001969747cbe5dbffac"; + const UPPER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003f7fffffff0000000000000000"; + const LOWER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003e7fffffff0000000000000000"; + const OFFSET: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda9172f614a037ddfffac0000000206200101000000260000000800000006000000000000000004801200000001a9695690b3e5f44a2f105e30d3ffac"; + let segment = |number: u32, kind: u8, data: &[u8]| { + let mut out = number.to_be_bytes().to_vec(); + out.extend_from_slice(&[kind, 0, 1]); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + out + }; + let side = 8u32.to_be_bytes(); + let page = |width: u32| { + let mut page = [width.to_be_bytes(), side].concat(); + page.extend_from_slice(&[0; 8]); // resolution + page.extend_from_slice(&[0, 0, 0]); // flags, striping + segment(0, 48, &page) + }; + let region = |width: u32, kind: u8, x: u32, y: u32, header: &[u8]| { + let mut region = [width.to_be_bytes(), side, x.to_be_bytes(), y.to_be_bytes()].concat(); + region.push(0); // combination operator + region.extend_from_slice(header); + region.extend_from_slice(&[0x5a; 64]); + [page(width), segment(1, kind, ®ion)].concat() + }; + let generic = |x, y| region(8, 38, x, y, &[0, 3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // template 0, AT pixels + // Immediate refinements of the page: template 0 with its usual AT pixels, with others, with typical + // prediction, and template 1. + let refined = |width, x, y, form: usize| { + region(width, 42, x, y, [&[0, 0xff, 0xff, 0xff, 0xff][..], &[0, 0xff, 0xff, 127, 127], &[2, 0xff, 0xff, 0xff, 0xff], &[1]][form]) + }; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let ink = |jbig2: Vec, width: u32, height: u32| { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 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".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&jbig2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count() + }; + assert!(ink(generic(2, 0), 8, 8) > 0, "a region on the page draws"); + for form in 0..4 { + assert!(ink(refined(8, 0, 0, form), 8, 8) > 0, "a refinement on the page draws: {form}"); + } + let end = 0x7FFF_FFFFu32; + for (x, y) in [(end, 0), (0, end), (end - 7, 0), (0, end - 7), (end - 129, 0), (0, end - 129)] { + assert_eq!(ink(generic(x, y), 8, 8), 0, "generic at {x:#x}, {y:#x}"); + for form in 0..4 { + assert_eq!(ink(refined(8, x, y, form), 8, 8), 0, "refinement {form} at {x:#x}, {y:#x}"); + } + } + // Past a 32-bit word of the row, where the reference is read again. + assert_eq!(ink(refined(64, 0, end, 0), 64, 8), 0, "a wide refinement"); + assert_eq!(ink(hex(FAR_CORNER), 16, 12), 0, "a symbol's far side past the end"); + assert_eq!(ink(hex(STRIP), 16, 12), 0, "a strip of -2^31"); + assert!(ink(hex(OFFSET), 16, 12) > 0, "a symbol refined by an offset of i32::MIN"); + assert_eq!(ink(hex(UPPER_LINE), 16, 12), 0, "an upper range line past an i32"); + assert_eq!(ink(hex(LOWER_LINE), 16, 12), 0, "a lower range line past an i32"); +} + /// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`, /// so everything after the cut was lost (the text and the title block of the A1 sample). Content /// that expands by a few times over is read to its end, however large it is. @@ -4864,6 +6276,77 @@ fn interpolated_minified_image_edges_snap_to_whole_pixels() { } } +/// #5530: an image placed turned or mirrored (by its own matrix or the page's `/Rotate`) snaps to +/// whole pixels just as an upright one does. Before, only upright images were snapped, so a turned +/// scan was resampled twice and every edge was a blended, soft pixel. This checks the edges, and +/// which way a mirrored image faces; `rotated_image_snap` in the integration tests checks which +/// way a turned page's image faces (`/Rotate` only turns, it never mirrors). +#[test] +fn a_turned_or_mirrored_minified_image_snaps_to_whole_pixels() { + let black = vec![0u8; 40 * 40]; + let image = hex(&black); + // Each matrix maps the image onto the user box 0.6..10.6 × 0.4..10.4, a different way round. + for matrix in [ + "0 10 -10 0 10.6 0.4", // a quarter turn + "-10 0 0 -10 10.6 10.4", // a half turn + "0 -10 10 0 0.6 10.4", // three quarters + "-10 0 0 10 10.6 0.4", // mirrored left to right + ] { + let content = format!("q {matrix} 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 11 11] /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 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1 + ); + 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(), "{matrix}: {:?}", page.error); + assert_eq!((page.width, page.height), (11, 11)); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // Device box 0.6..10.6 × 0.6..10.6 snaps to columns 1..11 and rows 1..11: no soft edge. + for y in 0..11 { + for x in 0..11 { + let inside = (1..11).contains(&x) && (1..11).contains(&y); + assert_eq!(gray(x, y), if inside { 0 } else { 255 }, "{matrix}: pixel ({x}, {y})"); + } + } + } + + // A uniform image looks the same mirrored, so mirror one whose left half (source columns + // 0..20) is black: drawn left to right reversed, the black half is on the right. + let halves: Vec = (0..40).flat_map(|_| (0..40).map(|x| if x < 20 { 0 } else { 255 })).collect(); + let halves = hex(&halves); + let content = "q -10 0 0 10 10.6 0.4 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 11 11] /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 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{halves}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + halves.len() + 1 + ); + 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); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // The snapped box is columns 1..11; columns 1..3 and 8..11 are well clear of the change at 6. + for y in 2..10 { + for x in 1..4 { + assert_eq!(gray(x, y), 255, "mirrored: the left of the box shows the image's right (white) half, pixel ({x}, {y})"); + } + for x in 8..11 { + assert_eq!(gray(x, y), 0, "mirrored: the right of the box shows the image's left (black) half, pixel ({x}, {y})"); + } + } +} + #[test] fn non_interpolated_gray_images_are_not_smoothed_when_minified() { let levels = [0u8, 255, 85, 170, 40, 200, 120, 230]; @@ -7008,6 +8491,99 @@ mod devicen_inks; // patch 27). mod isolated_cmyk_groups; +/// The generation-0 stand-in a rebuilt table files an object under must not make the object a +/// second object: enumeration yielded it once per key, so a document recovered without a readable +/// page tree reported the page twice (noted in review of #5580). +/// +/// And a later header for the same object number replaces the stand-in, so `N 0 R` cannot be left +/// pointing at a body the exact key no longer names. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object() { + // No table, no `startxref`, and no readable page tree: the pages are recovered by looking for + // objects that are pages, which is where a doubled object shows up as a doubled page. + let pdf = "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 3 99999 obj << /Type /Page /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length 0 >> stream\n\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default()); + let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(first.error.is_none(), "the page is recovered: {:?}", first.error); + let second = r.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(second.error.is_some(), "and it is one page, not two: page 2 must not exist"); + + // Two headers for object 3, the second the one the exact key names: the stand-in follows it. + // `(B)` is drawn by the later body, so the page that comes back says which one was read. + let twice = "%PDF-1.4\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 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 5 0 R >> endobj\n\ + 3 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >> endobj\n\ + 5 0 obj << /Length 0 >> stream\n\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(twice.to_string().into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "the page is found: {:?}", page.error); + assert_eq!((page.width, page.height), (300, 150), "`3 0 R` reads the later body, as `3 1` does"); +} + +/// A file whose cross-reference table has to be rebuilt keeps an object whose header names a +/// generation the reference does not. +/// +/// `poppler-85140-0.pdf` is 340 bytes with no table at all, and its one page's header reads +/// `3 18446744073709551616 obj` while the page tree points at `3 0 R`. Before #427 the digits +/// wrapped, and 2^64 wrapped to exactly 0, so the header read as `3 0 obj` and the file opened by +/// accident; once an overflowing integer became a real the object was unreachable and the file +/// stopped opening at all, which `cargo xtask check` reports as a regression (#5580). +/// +/// A rebuilt table has no revision history to tell two generations of an object apart -- the scan +/// already files objects found in object streams under generation 0 -- so the object number is +/// what identifies one. A readable generation that simply is not the one referenced +/// (`3 99999 obj`) was lost the same way, and is covered here too. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() { + for generation in [ + // The generation the reference names, which always worked. + "0", + // One that reads as an integer but is not the one referenced. + "99999", + // And one no generation may be: 2^64, which is what the reported file carries. A + // generation number is at most 65,535 (ISO 32000-2 §7.5.4). + "18446744073709551616", + ] { + // No table and no `startxref`, so the objects are recovered by scanning the file. + let pdf = format!( + "%PDF-1.4\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 {generation} obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n" + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "generation {generation}: the page is found: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 100), "generation {generation}: and it is the page's own size"); + } + + // A stray number before a header (`42 3 0 obj`) is not a header, and must not cost the one + // after it: the lenient read leaves the scan where it was when it fails (noted in review). + let pdf = "%PDF-1.4\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\ + 42 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "the header after a stray number is found: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 100)); +} + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 5323279ca1..7e8307d908 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -8,6 +8,7 @@ mod luminosity_group_space; mod named_form_properties; mod nonseparable_gray; mod open_type_cff_simple_font; +mod rotated_image_snap; mod search; mod separation_all; mod stray_q_in_a_form; diff --git a/crates/render/tests/it/rotated_image_snap.rs b/crates/render/tests/it/rotated_image_snap.rs new file mode 100644 index 0000000000..fcb5181d00 --- /dev/null +++ b/crates/render/tests/it/rotated_image_snap.rs @@ -0,0 +1,163 @@ +//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is +//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a +//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned +//! back, must match the upright one. The image itself sits at fractional coordinates: an upright +//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was +//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale +//! and offset (most of its pixels off, by up to 217 levels). +//! +//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil +//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another +//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so +//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are +//! not snapped upright or turned, and were already the same both ways; they stay in the test as a +//! guard. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage}; +use std::sync::Arc; + +/// A PDF of the given objects (object 1 the catalog), with a cross-reference table. +fn pdf(objects: &[Vec]) -> Vec { + 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", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + 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 << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + bytes.extend_from_slice(data); + bytes.extend_from_slice(b"\nendstream"); + bytes +} + +/// Image size in samples. +const W: usize = 400; +const H: usize = 500; + +/// A busy grey pattern with edges both ways, as a scan has. +fn grey_samples() -> Vec { + (0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect() +} + +/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes. +fn bit_samples() -> Vec { + let row = W.div_ceil(8); + let mut out = vec![0u8; row * H]; + for y in 0..H { + for x in 0..W { + if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 { + out[y * row + x / 8] |= 0x80 >> (x % 8); + } + } + } + out +} + +fn alpha_samples(w: usize, h: usize) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect() +} + +/// The kinds of image drawn. +const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"]; + +/// The image of `kind` as objects 5 (and 6, its soft mask). +fn image(kind: &str, interpolate: bool) -> Vec> { + let image = |depth: u8, extra: &str, data: &[u8]| { + let dict = format!( + "/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}" + ); + stream(&dict, data) + }; + let mask = |w: usize, h: usize| { + let dict = + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}"); + stream(&dict, &alpha_samples(w, h)) + }; + match kind { + "8-bit grey" => vec![image(8, "", &grey_samples())], + "1-bit grey" => vec![image(1, "", &bit_samples())], + "stencil mask" => vec![stream( + &format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"), + &bit_samples(), + )], + "soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)], + _ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)], + } +} + +/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn +/// at fractional coordinates inside it, in a colour (a stencil mask paints it). +fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec { + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"), + ]; + objects.extend(image(kind, interpolate)); + pdf(&objects) +} + +/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel, +/// and the 400 × 500 image is shrunk to about 250 × 300 pixels. +fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() }); + assert!(out.error.is_none(), "{kind}: {:?}", out.error); + out +} + +fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] { + let i = ((y * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] +} + +/// The largest channel difference between the turned render and the upright one turned the same +/// way. +fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 { + let upright = render(0, kind, interpolate); + let turned = render(rotate, kind, interpolate); + let (w, h) = (upright.width, upright.height); + // `/Rotate` turns the page clockwise. + let expected = |x: u32, y: u32| match rotate { + 90 => rgb(&upright, y, h - 1 - x), + 180 => rgb(&upright, w - 1 - x, h - 1 - y), + _ => rgb(&upright, w - 1 - y, x), + }; + let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) }; + assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}"); + let mut worst = 0; + for y in 0..th { + for x in 0..tw { + let (got, want) = (rgb(&turned, x, y), expected(x, y)); + worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max); + } + } + worst +} + +#[test] +fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() { + for kind in KINDS { + for interpolate in [true, false] { + for rotate in [90, 180, 270] { + let worst = turned_difference(rotate, kind, interpolate); + assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels"); + } + } + } +} diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 6248c1e9e1..0cac252c33 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec { /// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and /// never panics on malformed input. pub fn try_oid(dotted: &str) -> Result, SignError> { - let mut arcs = dotted.split('.').map(|a| a.parse::().map_err(|_| bad("invalid OID arc"))); + let mut arcs = dotted.split('.').map(|a| { + if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) { + return Err(bad("invalid OID arc")); + } + a.parse::().map_err(|_| bad("invalid OID arc")) + }); let first = arcs.next().ok_or_else(|| bad("empty OID"))??; let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??; if first > 2 || (first < 2 && second >= 40) { @@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec { mod tests { use super::*; + #[test] + fn try_oid_rejects_signs_and_empty_arcs() { + for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] { + assert!(try_oid(bad).is_err(), "accepted {bad}"); + } + assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549")); + } + #[test] fn round_trips() { let o = oid("1.2.840.113549.1.7.2"); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 804b5ea22a..7f164991d5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,7 @@ craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } +pdfcraft-content = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } @@ -96,6 +97,7 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts" image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" +proptest = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e00a11f430..c326b479fe 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -73,6 +73,7 @@ impl PdfCraftApp { default_mode: Mode::AllTools, view_defaults: Default::default(), units: Default::default(), + blank_paper: crate::paper::Paper::system(), date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, @@ -87,6 +88,7 @@ impl PdfCraftApp { space_hand: None, full_screen_tool: None, comment_prefs: Default::default(), + hotbar: Default::default(), theme: ThemeKind::Light, // A fresh install follows the desktop's appearance (#1248); saved settings override. theme_preference: ThemePreference::System, @@ -118,6 +120,7 @@ impl PdfCraftApp { all_tools_query: String::new(), all_tools_tab: (None, false), recent: Vec::new(), + recent_limit: crate::RECENT_FILES_DEFAULT, reopen_last_session: false, restore_last_view: true, last_views: Vec::new(), @@ -141,6 +144,9 @@ impl PdfCraftApp { #[cfg(all(test, not(target_arch = "wasm32")))] open_prepare_gate: None, pinned: Default::default(), + recent_check: Default::default(), + pinned_tools: Vec::new(), + pin_tool_search: String::new(), toast: None, toast_script: None, script_messages: std::collections::VecDeque::new(), @@ -228,7 +234,6 @@ impl PdfCraftApp { last_copied_file: None, system_clipboard: true, clipboard_override: None, - v_pressed: false, attach_override: None, duplicate_draft: None, page_props_draft: None, @@ -358,6 +363,46 @@ impl PdfCraftApp { pub fn render_pending(&self) -> bool { self.views.iter().any(|v| v.render_pending()) } + + /// Check whether a tool group is pinned to Home. + pub fn is_tool_pinned(&self, id: &str) -> bool { + self.pinned_tools.iter().any(|t| t == id) + } + + /// Pin a tool group to Home. Returns true if newly pinned. + pub fn pin_tool(&mut self, id: &str) -> bool { + if pdfcraft_engine::catalog::group(id).is_none() { + return false; + } + if !self.is_tool_pinned(id) { + self.pinned_tools.push(id.to_string()); + if let Some(g) = pdfcraft_engine::catalog::group(id) { + self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]); + } + return true; + } + false + } + + /// Unpin a tool group from Home. + pub fn unpin_tool(&mut self, id: &str) { + if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) { + self.pinned_tools.remove(pos); + if let Some(g) = pdfcraft_engine::catalog::group(id) { + self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]); + } + } + } + + /// Toggle a tool group's pinned state on Home. Returns whether it is now pinned. + pub fn toggle_pin_tool(&mut self, id: &str) -> bool { + if self.is_tool_pinned(id) { + self.unpin_tool(id); + false + } else { + self.pin_tool(id) + } + } } impl eframe::App for PdfCraftApp { @@ -367,6 +412,10 @@ impl eframe::App for PdfCraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // The button's release a window move or resize kept from egui. window_controls::release_after_desktop_grab(ctx, raw); + // The wheel after Alt+Tab back, before the mouse has moved (#1518). + let known = ctx.input(|i| i.pointer.latest_pos().is_some()); + let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size()); + drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx)); self.prepare_raw_input(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { return; @@ -404,6 +453,7 @@ impl eframe::App for PdfCraftApp { } self.poll_pending_opens(); } + self.check_restored_recent(); // Chinese wants its own faces before the Japanese ones (one baseline per line). let cjk = theme::CjkPreference::for_language(lang.code()); if !self.styled { diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 6641d93a86..9319368d29 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str { tl!("The document's security settings don't allow printing") } +enum ClipboardPaste { + Text, + Image(std::sync::Arc), +} + impl PdfCraftApp { /// Whether text and images may be copied out of document `id`; says why not when they may /// not. Reading aloud and accessibility tools don't ask (assistive technology may read @@ -22,14 +27,83 @@ impl PdfCraftApp { false } - /// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own). - pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) { - // ⌘C arrives as a Copy event on most platforms. - let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))); - if !copy || ctx.egui_wants_keyboard_input() { + /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). + pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let pastes: Vec<_> = ctx.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + egui::Event::Paste(_) => Some(ClipboardPaste::Text), + egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())), + _ => None, + }) + .collect() + }); + let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); + if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { + self.receive_pastes(i, pastes, false); return; } - self.copy_selected_text(i, ctx); + let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C))); + let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut))) + || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); + let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + + if copy + && !self.copy_selected_text(i, ctx) + && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.copy_page_image(sel.page, sel.index); + } + if cut + && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone()) + && let Some(id) = self.views.get(i).map(|v| v.id) + && self.may_copy(id) + { + self.cut_page_image(sel.page, sel.index); + } + if !pastes.is_empty() { + self.receive_pastes(i, pastes, true); + return; + } + if paste { + let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. })); + #[cfg(not(target_arch = "wasm32"))] + let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false }; + #[cfg(target_arch = "wasm32")] + let system_has_text = false; + + if !has_override_image && system_has_text { + self.last_copied_image = None; + self.last_copied_file = None; + return; + } + + let page = self.views.get(i).map_or(0, |v| v.current); + self.paste_image(page, None); + } + } + + fn receive_pastes(&mut self, view: usize, pastes: Vec, images_allowed: bool) { + let page = self.views.get(view).map_or(0, |view| view.current); + for paste in pastes { + match paste { + ClipboardPaste::Text => { + // Text fields may own the paste; the canvas cache still observes its contents. + self.last_copied_image = None; + self.last_copied_file = None; + } + ClipboardPaste::Image(image) if images_allowed => { + self.paste_received_image(page, &image); + } + ClipboardPaste::Image(_) => {} + } + } } /// Shared permission gate for copying selected page text from menus and keyboard events. @@ -79,3 +153,222 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod review_tests { + use super::*; + fn delivered() -> egui::Event { + egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN))) + } + + fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers } + } + + fn image_app() -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.system_clipboard = false; + app.create_blank(); + app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] }); + app + } + + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec) -> usize { + ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); + app.session.get(app.views[0].id).unwrap().page_images(0).len() + } + + #[test] + fn delivered_image_pastes_for_either_modifier_release_order() { + for command_first in [true, false] { + let mut app = image_app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]); + let releases = if command_first { + vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)] + } else { + vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)] + }; + assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste"); + assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste"); + assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1); + } + } + + #[test] + fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() { + for explicit_press in [false, true] { + let mut app = image_app(); + let ctx = egui::Context::default(); + let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()]; + if explicit_press { + first.push(key(true, egui::Modifiers::COMMAND)); + } + first.push(key(false, egui::Modifiers::COMMAND)); + assert_eq!(frame(&mut app, &ctx, 1.0, first), 1); + // The integration drops Windows-key identity; Win+V can leave only this release. + assert_eq!( + frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), + 1, + "opening history must not paste" + ); + assert_eq!( + frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), + 2, + "choosing an item produces exactly one intentional paste" + ); + } + } + + #[test] + fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() { + let mut app = image_app(); + let ctx = egui::Context::default(); + let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]); + assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0); + assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0); + assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0); + } + + #[test] + fn delivered_image_is_authoritative_and_not_duplicated_by_keys() { + let mut app = image_app(); + app.system_clipboard = true; + app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into())); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1); + assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]); + } + + #[test] + fn text_then_image_batch_pastes_image_and_keeps_its_cache() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1); + assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]); + } + + #[test] + fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1); + assert!(app.last_copied_image.is_none()); + assert!(app.last_copied_file.is_none()); + } + + #[test] + fn separate_delivered_images_in_one_batch_each_paste_once() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!( + frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), + 2 + ); + } + + #[test] + fn explicit_control_shortcut_still_pastes_once_without_an_image_event() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1); + assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1); + } + + #[test] + fn delivered_image_obeys_focus_modal_and_permission_guards() { + let ctx = egui::Context::default(); + let mut app = image_app(); + app.palette_open = true; + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0); + app.palette_open = false; + app.views[0].organize = true; + assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0); + app.views[0].organize = false; + app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { + permissions_password: Some("owner".into()), + ..Default::default() + })); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + let mut restricted = PdfCraftApp::new(); + restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap(); + assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification()); + assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0); + } + + #[test] + fn review_an_image_less_release_is_silent_but_explicit_paste_explains() { + let mut app = PdfCraftApp::new(); + app.system_clipboard = false; + app.create_blank(); + app.toast = None; + let ctx = egui::Context::default(); + ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::V, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.clipboard_keys(0, ui.ctx()), + ) + .drop_without_applying_deltas(); + assert!(app.toast.is_none()); + assert!(!app.paste_image(0, None)); + assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image"); + } + + #[test] + fn review_text_paste_release_is_silent_and_later_image_paste_still_works() { + let mut app = PdfCraftApp::new(); + app.system_clipboard = false; + app.create_blank(); + app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255])); + app.palette_open = true; // A text field owns the first event. + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); + assert!(app.last_copied_image.is_none()); + app.palette_open = false; + app.toast = None; + ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::V, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.clipboard_keys(0, ui.ctx()), + ) + .drop_without_applying_deltas(); + assert!(app.toast.is_none(), "the text gesture's key release must be silent"); + app.clipboard_override = None; + let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN))); + ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); + let id = app.active_ids().unwrap().1; + assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works"); + } + + #[test] + fn review_failed_keyboard_cut_keeps_the_image_selection() { + let mut app = PdfCraftApp::new(); + app.system_clipboard = false; + app.create_blank(); + app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0)); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); + assert!(app.views[0].image_selection.is_some()); + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 85fdae38af..e4078efd13 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -16,6 +16,18 @@ impl PdfCraftApp { pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> { let view = self.active.and_then(|i| self.views.get_mut(i)); match (key, view) { + ("hotbar-size", _) => { + let size: f32 = value.parse().map_err(|_| "hotbar-size must be 24–64 pixels")?; + if !size.is_finite() || !(24.0..=64.0).contains(&size) { + return Err("hotbar-size must be 24–64 pixels".into()); + } + self.hotbar.button_size = size; + } + ("hotbar-full-side", _) => { + self.hotbar.full_side = value.parse().map_err(|_| "hotbar-full-side must be true or false")?; + } + ("hotbar-tools", _) => self.hotbar.set_tools(value)?, + ("hotbar-side", _) => self.hotbar.place(value)?, ("language", _) => { let language = i18n::normalize_pref(value).ok_or_else(|| { let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect(); @@ -170,6 +182,12 @@ impl PdfCraftApp { _ => return Err("default-page-gaps must be on or off".into()), }; } + ("recent-files", _) => { + let max = crate::RECENT_FILES_MAX; + let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?; + self.recent_limit = limit; + self.recent.truncate(limit); + } ("cover", Some(v)) => { let on = match value { "on" => true, @@ -211,6 +229,13 @@ impl PdfCraftApp { _ => return Err("tools-side must be left or right".into()), } } + ("rail-side", _) => { + self.side_panels.rail_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("rail-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 5ef110d6aa..9991047077 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -332,8 +332,9 @@ impl PdfCraftApp { #[cfg(target_os = "macos")] macos_association::repair(std::path::Path::new(&p)); self.recent.retain(|r| r.path != p); + self.recent_check.opened(&p); self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); - self.recent.truncate(12); + self.recent.truncate(self.recent_limit); } // What the form's scripts said while it opened (messages, errors) shows now, not with // the next edit. diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 6021af64d8..c014453806 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -3,7 +3,6 @@ use super::*; impl PdfCraftApp { pub(super) fn shortcuts(&mut self, ctx: &egui::Context) { use egui::Key; - let pasted = self.paste_key_released(ctx); // "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document // properties; Escape cancels it rather than clearing a selection), and nothing may run // underneath it. They are read here, before the canvas can consume them. @@ -78,8 +77,7 @@ impl PdfCraftApp { prepare::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); - self.copy_key(i, ctx); - self.paste_image_key(i, ctx, pasted); + self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7dd9846a74..7eddee0af0 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -78,7 +78,7 @@ impl PdfCraftApp { let mut v = serde_json::json!({ "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, - "recent": self.recent, + "recent": self.recent.iter().take(self.recent_limit).collect::>(), "reopen_last_session": self.reopen_last_session, "restore_last_view": self.restore_last_view, "last_views": self.last_views_to_save(), @@ -139,11 +139,14 @@ impl PdfCraftApp { }); // Set apart: one more key inside the macro above passes serde_json's recursion limit. v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps); + v["recent_limit"] = serde_json::json!(self.recent_limit); v["fill_text_color"] = serde_json::json!(self.fill_text_color); v["redact_prefs"] = self.redact_defaults.to_json(); + v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); + v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); @@ -155,6 +158,10 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Ok(mut bar) = serde_json::from_value::(v["hotbar"].clone()) { + bar.normalize(); + self.hotbar = bar; + } let panels = side_panels::restore(&v); self.side_panels = panels.panels; if let Some(open) = panels.tools_open { @@ -175,11 +182,16 @@ impl PdfCraftApp { self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end()); } if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { - // Only keep entries whose files still exist. - #[cfg(not(target_arch = "wasm32"))] - let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); + // Entries whose files are gone leave once the window is up: asking here could block + // startup on a mount that has stopped answering (#5682). + self.recent_check.restored(r.iter().map(|f| f.path.clone()).collect()); self.recent = r; } + // Nonsense keeps the limit it had; settings from before the limit have none. + if let Some(limit) = v["recent_limit"].as_u64() { + self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX)); + } + self.recent.truncate(self.recent_limit); self.restore_layout_prefs(&v); self.restore_behavior_prefs(&v); self.restore_view_prefs(&v); @@ -207,6 +219,18 @@ impl PdfCraftApp { self.show_secured_notice = on; } self.pinned.restore(&v["pinned_folders"]); + if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { + let mut tools = Vec::new(); + for item in items { + if let Some(id) = item.as_str() + && pdfcraft_engine::catalog::group(id).is_some() + && !tools.iter().any(|t| t == id) + { + tools.push(id.to_string()); + } + } + self.pinned_tools = tools; + } if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5625587a69..a220644421 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -84,6 +84,175 @@ pub enum Fit { None, } +use crate::edit_text_ui::ImageHandle; + +/// Extension trait on `egui::Painter` to draw polygons. +pub trait PainterExt { + fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx; +} + +impl PainterExt for egui::Painter { + fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx { + self.add(egui::Shape::convex_polygon(points, fill, stroke)) + } +} + +/// User-space selectable bounding box for an image object. +/// If the image object has an active clipping path applied to it (the crop box), +/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { + if let Some(clip) = im.clip_rect { + let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; + if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { + return r; + } + } + im.visible_rect.unwrap_or(im.rect) +} + +/// Screen-space selectable bounding box for an image object. +/// If the image object has an active clipping path applied to it (the crop box), +/// the final selectable `egui::Rect` is the mathematical intersection of the image's +/// full CTM-derived rect and the clipping path's rect. +pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { + let full = xf.user_rect_f64(info, page, im.rect); + if let Some(clip) = im.clip_rect { + let clip_r = xf.user_rect_f64(info, page, clip); + let inter = full.intersect(clip_r); + if inter.is_positive() { + return inter; + } + } + if let Some(vr) = im.visible_rect { + let r = xf.user_rect_f64(info, page, vr); + if r.is_positive() { + return r; + } + } + full +} + +/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, +/// with 8 resize handles and rotated local axes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImageObb { + pub s00: Pos2, + pub s10: Pos2, + pub s11: Pos2, + pub s01: Pos2, + pub s_bc: Pos2, + pub s_rc: Pos2, + pub s_tc: Pos2, + pub s_lc: Pos2, + pub s_rot: Pos2, + pub screen_center: Pos2, + pub u_axis: (f64, f64), + pub v_axis: (f64, f64), + pub is_rotated: bool, +} + +impl ImageObb { + pub(crate) fn handle_cursor(&self, handle: ImageHandle) -> egui::CursorIcon { + let direction = self.handle_pos(handle) - self.screen_center; + match handle { + ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => { + if direction.x * direction.y > 0.0 { + egui::CursorIcon::ResizeNwSe + } else { + egui::CursorIcon::ResizeNeSw + } + } + _ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal, + _ => egui::CursorIcon::ResizeVertical, + } + } + + pub(crate) fn contains(&self, visible: Rect, p: Pos2) -> bool { + (visible.is_positive() && visible.contains(p)) || crate::edit_text_ui::quad_contains(self.corners(), p) + } + + pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self { + let m = crate::edit_text_ui::Matrix(matrix); + let p00 = m.apply(0.0, 0.0); + let p10 = m.apply(1.0, 0.0); + let p11 = m.apply(1.0, 1.0); + let p01 = m.apply(0.0, 1.0); + + let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1); + let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1); + let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1); + let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1); + + let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5); + let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5); + let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5); + let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5); + + let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25); + let out_dir = s_tc - screen_center; + let out_len = out_dir.length().max(1e-4); + let out_norm = out_dir / out_len; + let s_rot = s_tc + out_norm * 24.0; + + let [a, b, c, d, ..] = matrix; + let w_u = (a * a + b * b).sqrt().max(1e-6); + let h_v = (c * c + d * d).sqrt().max(1e-6); + let u_axis = (a / w_u, b / w_u); + let v_axis = (c / h_v, d / h_v); + // The fast rectangle path interprets labels in screen space. Use matrix + // resizing whenever the image's user-space axes are turned or reflected + // on screen, including page /Rotate and View > Rotate View. + let u = s10 - s00; + let v = s01 - s00; + let is_rotated = u.y.abs() > 1e-4 || v.x.abs() > 1e-4 || u.x < 0.0 || v.y > 0.0; + + Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center, u_axis, v_axis, is_rotated } + } + + pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self { + let [a, b, c, d, ..] = im.matrix; + let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4 || a < 0.0 || d < 0.0; + let bounds = image_selectable_bounds(im); + let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] { + [bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]] + } else { + im.matrix + }; + Self::from_matrix(matrix, xf, info, page) + } + + pub fn handles(&self) -> [(ImageHandle, Pos2); 8] { + [ + ImageHandle::TopLeft, + ImageHandle::TopCenter, + ImageHandle::TopRight, + ImageHandle::RightCenter, + ImageHandle::BottomRight, + ImageHandle::BottomCenter, + ImageHandle::BottomLeft, + ImageHandle::LeftCenter, + ] + .map(|h| (h, self.handle_pos(h))) + } + + pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 { + match handle { + ImageHandle::TopLeft => self.s01, + ImageHandle::TopCenter => self.s_tc, + ImageHandle::TopRight => self.s11, + ImageHandle::RightCenter => self.s_rc, + ImageHandle::BottomRight => self.s10, + ImageHandle::BottomCenter => self.s_bc, + ImageHandle::BottomLeft => self.s00, + ImageHandle::LeftCenter => self.s_lc, + } + } + + pub fn corners(&self) -> [Pos2; 4] { + [self.s00, self.s10, self.s11, self.s01] + } +} + /// The page display as it was before full screen, put back on leaving it /// ([`DocView::enter_presentation`]). #[derive(Clone, Copy, Debug, PartialEq)] @@ -569,6 +738,104 @@ pub enum ViewAction { CameraNext(&'static str), } +/// Default page right-click menu actions. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PageMenuAction { + RotateCCW, + RotateCW, + Print, + SelectTool, + HandTool, + MarqueeZoom, + Copy, + Paste(usize, [f64; 2]), + AddComment(usize, [f64; 2]), + AddBookmark, + TakeSnapshot, + EditPdf, + AddText, + AddImage, + DeletePage(usize), + ExportPdf, + Find, + DocProperties, + FlattenComments, +} + +pub(crate) struct PageMenuPerms { + pub can_modify: bool, + pub can_assemble: bool, + pub can_annotate: bool, + pub can_print: bool, + pub can_copy: bool, + pub can_copy_items: bool, + pub can_paste: bool, + pub has_annotations: bool, +} + +pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option) { + use PageMenuAction as A; + // Takes `choice` when `entry` was clicked, and closes the menu. + let mut pick = |entry: egui::Response, ui: &mut egui::Ui, choice: PageMenuAction| { + if entry.clicked() { + *action = Some(choice); + ui.close(); + } + }; + let icon = |ui: &mut egui::Ui, name: &str, tip: &str| crate::icons::button(ui, name, 24.0, false, tip); + // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + pick(icon(ui, "rotate-ccw", tl!("Rotate view counterclockwise")), ui, A::RotateCCW); + ui.add_enabled_ui(perms.can_print, |ui| pick(icon(ui, "printer", tl!("Print")), ui, A::Print)); + pick(icon(ui, "mouse-pointer-2", tl!("Select Tool")), ui, A::SelectTool); + pick(icon(ui, "hand", tl!("Hand Tool")), ui, A::HandTool); + pick(icon(ui, "zoom-in", tl!("Marquee Zoom")), ui, A::MarqueeZoom); + }); + + ui.separator(); + + pick( + ui.add_enabled( + perms.can_copy_items, + egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)), + ), + ui, + A::Copy, + ); + pick( + ui.add_enabled( + perms.can_paste, + egui::Button::new(tl!("Paste")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), pdfcraft_engine::commands::Shortcut::cmd("V"))), + ), + ui, + A::Paste(page, at), + ); + + ui.separator(); + + pick(ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))), ui, A::AddComment(page, at)); + if perms.has_annotations { + pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))), ui, A::FlattenComments); + } + pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))), ui, A::AddBookmark); + pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))), ui, A::TakeSnapshot); + + ui.separator(); + + pick(ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())), ui, A::EditPdf); + pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))), ui, A::AddText); + pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))), ui, A::AddImage); + pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))), ui, A::DeletePage(page)); + pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))), ui, A::ExportPdf); + + ui.separator(); + + pick(ui.button(tl!("Find text or tools")), ui, A::Find); + pick(ui.button(tl!("Rotate view clockwise")), ui, A::RotateCW); + pick(ui.button(tl!("Document properties…")), ui, A::DocProperties); +} + impl DocView { /// Whether a middle-button scrolling gesture is active (tests and automation). pub fn auto_scrolling(&self) -> bool { @@ -886,6 +1153,7 @@ struct PageEnv<'a> { fill_text_size: f64, fill_text_color: [f64; 3], today: (i64, u32, u32), + can_paste_image: bool, } /// What the page-scroll closure produces for the rest of the frame. @@ -903,6 +1171,7 @@ struct PageOut { open_initials: bool, open_signature: bool, open_table: Option<(usize, [f64; 4])>, + page_action: Option, primary_down: bool, refused: Option, stamp_placed: bool, @@ -946,6 +1215,70 @@ struct PageSlot { /// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders /// it wants. +#[allow(clippy::too_many_arguments)] +fn paint_image_suppressed( + xf: &PageXform, + painter: &egui::Painter, + tex: egui::TextureId, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + suppress: Option<[f32; 4]>, + is_full_page_tex: bool, +) { + let Some([su0, sv0, su1, sv1]) = suppress else { + if is_full_page_tex { + xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); + } else { + xf.paint_image(painter, tex, u0, v0, u1, v1); + } + return; + }; + let iu0 = u0.max(su0); + let iu1 = u1.min(su1); + let iv0 = v0.max(sv0); + let iv1 = v1.min(sv1); + if iu0 >= iu1 || iv0 >= iv1 { + if is_full_page_tex { + xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); + } else { + xf.paint_image(painter, tex, u0, v0, u1, v1); + } + return; + } + let map_uv = |pu: f32, pv: f32| -> (f32, f32) { + if is_full_page_tex { + (pu, pv) + } else { + let du = (u1 - u0).max(1e-6); + let dv = (v1 - v0).max(1e-6); + ((pu - u0) / du, (pv - v0) / dv) + } + }; + let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| { + if pu1 <= pu0 || pv1 <= pv0 { + return; + } + let (tu0, tv0) = map_uv(pu0, pv0); + let (tu1, tv1) = map_uv(pu1, pv1); + xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1); + }; + + if iv0 > v0 { + draw_strip(u0, v0, u1, iv0); + } + if v1 > iv1 { + draw_strip(u0, iv1, u1, v1); + } + if iu0 > u0 { + draw_strip(u0, iv0, iu0, iv1); + } + if u1 > iu1 { + draw_strip(iu1, iv0, u1, iv1); + } +} + fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; let PageSlot { i, r, xf } = *slot; @@ -988,13 +1321,33 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), None => frame.wanted.push((i, want_scale, want_tag, None)), } + let doc = frame.env.doc; + let active_drag_suppress = if let Some(sel) = view.image_selection.clone() + && sel.page == i + && sel.drag.is_some() + { + let images = cached_edit_images(view, doc, i); + if let Some(img) = images.get(sel.index) { + let obb = ImageObb::from_image(img, &xf, info, i); + let uvs = obb.corners().map(|c| xf.screen_to_norm(c)); + let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0); + let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0); + let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0); + let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0); + if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None } + } else { + None + } + } else { + None + }; match view.pages.get(&i) { Some(p) => { if !tiled && p.tag == want_tag { - xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true); } else { // A backdrop, or a raster at an older scale until the new one arrives. - xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true); } } None => { @@ -1005,14 +1358,21 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra } } if tiled && r.intersects(visible) { - paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side)); + paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress); } } } /// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, /// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. -fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) { +fn paint_page_tiles( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + (pw_pt, ph_pt, max_side): (f32, f32, f32), + active_drag_suppress: Option<[f32; 4]>, +) { let PageEnv { ppp, scale, tag, .. } = frame.env; let PageSlot { i, r, xf } = *slot; let visible = frame.visible; @@ -1041,7 +1401,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_> let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); let [w, h] = tex.size(); let (x, y) = (ox as f32, oy as f32); - xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); + paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false); } // Visible tiles, nearest the viewport centre first: each tile costs a full // page interpret, so the workers sharpen what is looked at before the edges. @@ -1066,13 +1426,16 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_> frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { let (fw, fh) = (dw as f32, dh as f32); - xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( + paint_image_suppressed( + &xf.texel_aligned([dw as usize, dh as usize], ppp), painter, tex.id(), x as f32 / fw, y as f32 / fh, (x + w) as f32 / fw, (y + h) as f32 / fh, + active_drag_suppress, + false, ); } } @@ -1327,9 +1690,18 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< info, allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), }; - let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); !others_own_pointer && (text_used || image_used) }; + if tool == QuickTool::EditText { + if others_own_pointer { + if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { + view.image_selection = None; + } + } else { + crate::content_ui::sync_image_selection(view, i, &images, added); + } + } let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); if tool == QuickTool::Select && resp.secondary_clicked() @@ -1367,17 +1739,20 @@ fn edit_inventory( l } }; - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; + let images = cached_edit_images(view, doc, i).to_vec(); (lines, images) } +/// Images parsed once per page and document edit generation, shared by painting and menus. +fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] { + let generation = doc.edit_generation(); + let entry = view.edit_images.entry(page).or_insert_with(|| (generation, doc.page_images(page))); + if entry.0 != generation { + *entry = (generation, doc.page_images(page)); + } + &entry.1 +} + /// What the page modules have under a point on one page, for the page router. struct PageHits<'a> { page: usize, @@ -1426,6 +1801,19 @@ impl PageHits<'_> { images, clip, } = *self; + // Crop mode owns presses on its page, including the controls beside its + // frame. Underlying paragraphs and the empty-page marquee must not take + // a crop button's press before the image module receives it. + if tool == QuickTool::EditText + && can_modify + && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some()) + { + return vec![crate::page_router::Hit::new( + crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body }, + crate::page_cursor::Layer::Handle, + egui::CursorIcon::Default, + )]; + } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { @@ -1935,6 +2323,7 @@ fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) } } +#[allow(clippy::cognitive_complexity)] fn draw_pages( ui: &mut egui::Ui, viewport: Rect, @@ -1968,6 +2357,8 @@ fn draw_pages( prefs, preparing, selection_wheel, + ppp, + rects, single_paging, tool, unobstructed, @@ -2034,6 +2425,38 @@ fn draw_pages( if resp.secondary_clicked() { // Set again below when the right-click lands on a link. view.links.context = None; + let mut over_image = false; + if tool == QuickTool::EditText { + let pointer_pos = ptr.pos; + if let Some(p) = pointer_pos { + for &page_idx in visible_pages { + let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp); + if !r.intersects(visible) { + continue; + } + let xf = + PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; + let images = cached_edit_images(view, doc, page_idx).to_vec(); + for (idx, img) in images.iter().enumerate().rev() { + let obb = ImageObb::from_image(img, &xf, info, page_idx); + let b = image_screen_rect(img, &xf, info, page_idx); + if obb.contains(b, p) { + over_image = true; + if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { + view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx)); + } + break; + } + } + if over_image { + break; + } + } + } + } + if !over_image && tool == QuickTool::EditText { + view.image_selection = None; + } } let mut frame = PageFrame { env: *env, @@ -2063,20 +2486,22 @@ fn draw_pages( let PageOut { mut canvas_action, clicked_link, - mut copy_selected, + copy_selected, mut field_menu, field_placed, field_props, hover_text: _, - image_action, + mut image_action, open_boxes, open_initials, open_signature, open_table, + page_action: _, primary_down: _, refused, stamp_placed, } = page_out; + let mut page_action: Option = None; let painter = ui.painter(); if calc_order { view.calc_order.set_hover_page(calc_hover); @@ -2196,20 +2621,33 @@ fn draw_pages( } return; } - if view.comments.selected.is_none() { - let selected = view.selected_text().is_some_and(|t| !t.is_empty()); - let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); - if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { - copy_selected = true; - ui.close(); - } - ui.separator(); + let can_paste_image = env.can_paste_image; + let is_image_menu = tool == QuickTool::EditText + && (ui.ctx().data_mut(|d| d.get_temp::(crate::edit_text_ui::image_menu_id())).unwrap_or(false) + || view.image_selection.clone().is_some_and(|sel| { + let pointer_pos = ptr.pos; + let Some(p) = pointer_pos else { return false }; + let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; + let images = cached_edit_images(view, doc, sel.page).to_vec(); + let Some(img) = images.get(sel.index) else { return false }; + let obb = ImageObb::from_image(img, &xf, info, sel.page); + let b = image_screen_rect(img, &xf, info, sel.page); + obb.contains(b, p) + })); + if is_image_menu && let Some(sel) = view.image_selection.clone() { + let images = cached_edit_images(view, doc, sel.page).to_vec(); + let at = view.comments.context_at.map(|(_, at)| at); + let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360; + crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); + return; + } + if view.comments.selected.is_some() { + canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); + return; } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { + if let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. let key = egui::Id::unique("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); @@ -2233,9 +2671,28 @@ fn draw_pages( ui.separator(); } } - canvas_action = - comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image); + let has_copy_image = copy_image.is_some(); + if copy_image.is_some() { + canvas_action = copy_image; + } + let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); + let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image); + let can_paste = can_paste_image && can_modify; + let perms = PageMenuPerms { + can_modify, + can_assemble: doc.allows_assembly(), + can_annotate: allowed, + can_print: doc.allows_printing(), + can_copy, + can_copy_items, + can_paste, + has_annotations: !info.annotations.is_empty(), + }; + default_page_context_menu(ui, page, at, perms, &mut page_action); }); + if !resp.context_menu_opened() { + ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); + } *out = PageOut { canvas_action, clicked_link, @@ -2249,6 +2706,7 @@ fn draw_pages( open_initials, open_signature, open_table, + page_action, primary_down: ptr.primary_down, refused, stamp_placed, @@ -2359,6 +2817,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not // scroll the page just shown away from its top (or bottom). view.precise_scroll_until = now + 0.25; + view.fling.stop(); } // Paging owns the vertical axis in single-page view; leave it to the pager. // The rest of a gesture that turned the page is swallowed too, or it would pan the new page @@ -2992,6 +3451,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: /// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let can_paste_key = egui::Id::unique("can-paste-image"); + let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) { + let can = app.can_paste_image(); + ui.data_mut(|d| d.insert_temp(can_paste_key, can)); + can + } else { + ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false) + }; // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); if key_scroll != 0.0 { @@ -3229,16 +3696,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, today, + can_paste_image, }; let mut page_out = PageOut::default(); - let fade_strength = ui.spacing().scroll.fade.strength; - if !app.show_page_edge_shadows { - ui.spacing_mut().scroll.fade.strength = 0.0; - } - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| { draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) }); - ui.spacing_mut().scroll.fade.strength = fade_strength; let PageOut { canvas_action, clicked_link, @@ -3252,6 +3715,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_initials, open_signature, open_table, + page_action, primary_down, mut refused, stamp_placed, @@ -3291,8 +3755,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if preparing { crate::prepare::keys(ui.ctx(), view); } + if tool != QuickTool::EditText { + view.image_selection = None; + crate::content_ui::release_image_selection(view, &added); + } if editing_content { - crate::content_ui::keys(ui.ctx(), view); + crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText); } if tool == QuickTool::Link { crate::link_ui::keys(ui.ctx(), view); @@ -3338,6 +3806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { field_props, field_menu, image_action, + page_action, refused, open_signature, open_initials, @@ -3359,6 +3828,7 @@ struct FrameRequests { field_props: Option<(String, usize)>, field_menu: Option, image_action: Option, + page_action: Option, refused: Option, open_signature: bool, open_initials: bool, @@ -3373,12 +3843,14 @@ struct FrameRequests { } /// Opens the dialogs and runs the app commands the frame asked for. +#[allow(clippy::cognitive_complexity)] fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { let FrameRequests { open_props, field_props, field_menu, image_action, + page_action, refused, open_signature, open_initials, @@ -3442,12 +3914,109 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, } Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => { - app.views[index].image_selection = None; app.cut_page_image(page, image); } + Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => { + if let Some(pt) = at { + app.paste_image_at(page, pt); + } else { + app.paste_image(page, None); + } + } + Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { + app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); + } Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index), Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index), Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page), + Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]; + app.views[index].comments.context_at = Some((page, center)); + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at: center, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + } + } + None => {} + } + match page_action { + Some(PageMenuAction::Copy) => { + if let Some(sel) = app.views[index].image_selection.clone() { + app.copy_page_image(sel.page, sel.index); + } else if app.views[index].selected_text().is_some() { + app.execute("edit.copy"); + } else if let Some((page, at)) = app.views[index].comments.context_at + && let Some(doc) = app.session.get(app.views[index].id) + && let Some(image) = doc.page_image_at(page, at) + { + app.copy_page_image(page, image); + } + } + Some(PageMenuAction::Paste(page, at)) => { + app.paste_image_at(page, at); + } + Some(PageMenuAction::RotateCCW) => { + app.views[index].rotate_view(false); + } + Some(PageMenuAction::RotateCW) => { + app.views[index].rotate_view(true); + } + Some(PageMenuAction::Print) => { + app.open_print(); + } + Some(PageMenuAction::SelectTool) => { + app.quick_tool = QuickTool::Select; + } + Some(PageMenuAction::HandTool) => { + app.quick_tool = QuickTool::Hand; + } + Some(PageMenuAction::MarqueeZoom) => { + app.quick_tool = QuickTool::MarqueeZoom; + } + Some(PageMenuAction::AddComment(page, at)) => { + app.views[index].comments.composer = + Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true }); + } + Some(PageMenuAction::FlattenComments) => { + app.views[index].comments.selected = None; + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false }); + } + Some(PageMenuAction::AddBookmark) => { + app.bookmark_action(crate::panels::BmAction::New); + } + Some(PageMenuAction::TakeSnapshot) => { + app.quick_tool = QuickTool::Snapshot; + } + Some(PageMenuAction::EditPdf) => { + app.quick_tool = QuickTool::EditText; + } + Some(PageMenuAction::AddText) => { + app.quick_tool = QuickTool::AddText; + } + Some(PageMenuAction::AddImage) => { + app.add_image_dialog(); + } + Some(PageMenuAction::DeletePage(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] }); + } + Some(PageMenuAction::ExportPdf) => { + app.select_tool("export"); + } + Some(PageMenuAction::Find) => { + app.views[index].open_find(); + } + Some(PageMenuAction::DocProperties) => { + app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); + } None => {} } if let Some(why) = refused { @@ -3487,6 +4056,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { quick_bar(app, avail, ui); + crate::hotbar::show(app, avail, ui.ctx()); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 068661ea5f..0d934dc8c9 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -32,14 +32,22 @@ pub(super) const SUPPRESS_SECS: f64 = 0.25; /// Short rolling extension while egui still has an unprocessed wheel tail. pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05; -// Keep the viewer's scroll identity tied to the document instead of its parent UI. +// Keep the viewer's scroll identity tied to the document instead of its parent UI. Without +// `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only. pub(super) fn page_scroll( ui: &mut egui::Ui, scroll: egui::ScrollArea, id: egui::Id, + fades: bool, draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, ) -> egui::scroll_area::ScrollAreaOutput { - ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner + let strength = ui.spacing().scroll.fade.strength; + if !fades { + ui.spacing_mut().scroll.fade.strength = 0.0; + } + let out = ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner; + ui.spacing_mut().scroll.fade.strength = strength; + out } /// Run a push button's action (the ones that need no JavaScript engine). diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..4ad2bf05db 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -279,7 +279,15 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te /// The floating quick-action bar at the left edge of the document area. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let pos = area.left_top() + vec2(14.0, 14.0); + let mut offset = vec2(0.0, 0.0); + if app.hotbar.full_side { + match app.hotbar.dock.as_deref() { + Some("left") => offset.x += app.hotbar.button_size + 14.0, + Some("top") => offset.y += app.hotbar.button_size + 14.0, + _ => {} + } + } + let pos = area.left_top() + vec2(14.0, 14.0) + offset; egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) @@ -302,6 +310,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let current = app.comment_prefs.group_tool[g]; let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); + if comments::GROUPS[g].len() == 1 { + if resp.clicked() { + app.execute(current.command()); + } + continue; + } // A small corner triangle marks the flyout. let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index a0ad8fb335..4b6f6c2e42 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -239,6 +239,12 @@ impl DocView { self.texts.get(&page).cloned() } + /// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and + /// find matches on a page all wait for it (tests and automation). + pub fn has_page_text(&self, page: usize) -> bool { + self.texts.contains_key(&page) + } + pub fn thumb(&self, page: usize) -> Option<&TextureHandle> { self.thumbs.get(&page).map(|t| &t.tex) } diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 403e9836c0..cc938170da 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -46,8 +46,16 @@ pub(crate) fn page_input( let u = info_page.view_to_user(vx, vy); let at = [f64::from(u[0]), f64::from(u[1])]; let generation = doc.edit_generation(); - view.picked_image = - doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation })); + view.picked_image = doc.page_image_at(page, at).and_then(|index| { + doc.page_images(page).get(index).and_then(|i| { + let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page); + if screen_rect.is_positive() && screen_rect.contains(p) { + Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation }) + } else { + None + } + }) + }); } /// Forget the selected picture once the document has been edited since it was picked: Edit text & @@ -71,7 +79,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: } /// A file name without characters file systems refuse. -fn safe_name(s: &str) -> String { +pub(crate) fn safe_name(s: &str) -> String { let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect(); let cleaned = cleaned.trim().trim_matches('.').to_string(); if cleaned.is_empty() { "PdfCraft".into() } else { cleaned } @@ -171,7 +179,8 @@ impl PdfCraftApp { return; } // The document may have changed since the click: the same picture must still be there. - let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect)); + let current = + self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds)); if current == Some(picked.rect) { self.copy_page_image_file(picked.page, picked.index); } else { @@ -195,6 +204,7 @@ impl PdfCraftApp { let copied = file.and_then(|(ext, bytes)| { let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1)); self.put_file_on_clipboard(&name, &bytes)?; + self.last_copied_image = None; self.last_copied_file = Some((name, bytes)); Ok(ext) }); @@ -213,7 +223,7 @@ impl PdfCraftApp { } let dir = clipboard_dir().ok_or("there is no folder for the copied file")?; let path = write_clipboard_file(&dir, name, bytes)?; - arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string()) + crate::os_clipboard::with(|c| c.set().file_list(&[path])).map_err(|e| e.to_string()) } #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index f0df1d92f1..02cb2208ed 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -56,18 +56,17 @@ impl PagePointer { // pointer is outside the window. With a dialog open only a drag counts, up to the frame // it stops: on the frame one opens egui still gives the pages a press that lands under // it, and its click. - let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some()); - // `contains_pointer` alone also holds a few points under the edge of an Area over the - // pages (egui's interact radius), so the top layer there must be the pages' own. A - // dialog covers them from the frame it opens, before egui knows its layer. let layer = ui.layer_id(); let on_top = |p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let press_origin = origin.or_else(|| first_press(&edges)); + let held = + resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; } - Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down } + Self { pos, origin: press_origin, edges, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 06d3c48568..8b91cc865c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -42,8 +42,7 @@ pub(crate) enum Owner { pub(crate) enum PicturePart { /// The picture: a click selects it, a drag moves the selected one. Body, - /// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag - /// resizes it. + /// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints). Corner(usize), /// The selected picture's rotate knob: a drag turns it. Knob, diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a885111..4761584735 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2002,3 +2002,38 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].stale_tiles.clear(); h.state_mut().views[0].tiles.clear(); } + +#[test] +fn image_handles_account_for_document_and_view_rotation() { + for page_rotation in [0, 90, 180, 270] { + for view_rotation in [0, 90, 180, 270] { + let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) }; + let info = DocInfo { + pages: vec![pdfcraft_render::PageInfo { + width, + height, + label: String::new(), + crop: [0.0, 0.0, 300.0, 400.0], + rotation: page_rotation, + }], + ..Default::default() + }; + let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) }; + let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height }; + let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0); + let total = (page_rotation + view_rotation) % 360; + assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned"); + for (handle, point) in obb.handles() { + let direction = point - obb.screen_center; + let cursor = match handle { + ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => { + if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw } + } + _ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal, + _ => egui::CursorIcon::ResizeVertical, + }; + assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}"); + } + } + } +} diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 91a3739d62..0f6249c8a8 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -17,7 +17,7 @@ impl DocView { } /// Where `page` is drawn this frame, with its coordinate mapping. - pub(crate) fn page_xform(&self, page: usize) -> Option { + pub fn page_xform(&self, page: usize) -> Option { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf) } } @@ -128,6 +128,10 @@ impl PageXform { Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1)) } + pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { + self.user_rect(info, page, r.map(|v| v as f32)) + } + /// This transform resized to a raster of `px` device pixels (width, height before the view /// rotation), so each of its texels covers one screen pixel. The rectangle's corner must /// already be on a whole pixel ([`snap_to_pixels`]). @@ -148,4 +152,34 @@ impl PageXform { mesh.add_triangle(0, 2, 3); painter.add(egui::Shape::mesh(mesh)); } + + /// Draw a sub-rectangle of a whole-page raster texture with specific texture UVs covering the normalised page region. + #[allow(clippy::too_many_arguments)] + pub fn paint_image_region( + &self, + painter: &egui::Painter, + tex: egui::TextureId, + pu0: f32, + pv0: f32, + pu1: f32, + pv1: f32, + tu0: f32, + tv0: f32, + tu1: f32, + tv1: f32, + ) { + let mut mesh = egui::Mesh::with_texture(tex); + let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)]; + for (u, v, tu, tv) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } + + /// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space. + pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) { + self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1); + } } diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 5a0dcf6733..159adf2b7e 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -145,7 +145,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] if self.system_clipboard { let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; - arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?; } self.last_snapshot = Some((w, h, out.rgba)); Ok((w, h)) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..59525aae7b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,6 +1,6 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. -use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; +use craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; @@ -572,13 +572,18 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) - .edge(Edge::End) + .edge(app.side_panels.rail_edge()) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; + let right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { view.zoom_step(false); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 08d2040e23..f072b7febe 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -259,6 +259,8 @@ impl PdfCraftApp { self.notify(error); } } + "view.select_tool" => self.quick_tool = crate::QuickTool::Select, + "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3c730ed68..44e5481a11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,11 @@ //! `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. //! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts; +//! `hotbar-tools` accepts comma-separated command IDs (empty clears the bar), and +//! `hotbar-side=float|left|right|top|bottom` floats or docks the customizable hotbar; +//! `hotbar-full-side=true|false` extends a docked bar along the entire side. +//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` +//! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 64b10b69fb..b803f2738c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -133,9 +133,11 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, + "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, + "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfb0da686a..048d519222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub(super) mod credits; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; +pub(super) mod preferences; mod prompts; pub(super) mod protect; pub(super) mod standards_ui; @@ -127,7 +128,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::Properties(_) => 640.0, Dialog::Print => 820.0, Dialog::FieldProps | Dialog::BulkFieldProps => 600.0, + Dialog::Preferences => 610.0, Dialog::About => 780.0, + Dialog::PinTool => 540.0, _ => 520.0, }); if dialog_body(ui, app, dialog, &mut req) { @@ -446,6 +449,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } } + Dialog::PinTool => { + if !dialog_pin_tool(ui, app, req) { + return false; + } + } } true } @@ -1080,6 +1088,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi use pdfcraft_engine::{PageOrientation as O, PageParity as P}; let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); let d = &mut app.rotate_draft; + let mut typing_to = false; ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -1101,10 +1110,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi widgets::radio_value(ui, &mut d.which, 0, tl!("All")); widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.which, 2, tl!("From")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); + let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + typing_to = + widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id)); ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); }); }); @@ -1136,10 +1146,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi }); ui.end_row(); }); - d.to = d.to.max(d.from); + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, tl!("OK"), true).clicked() { + // OK while the end is still being typed (a screen reader can press it). + widgets::keep_range_ordered(d.from, &mut d.to, false); req.rotate_now = true; req.close = true; } @@ -1161,17 +1173,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe ui.add_space(8.0); ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.all, false, tl!("From")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - d.to = d.to.max(d.from); + let typing_to = ui + .horizontal(|ui| { + widgets::radio_value(ui, &mut d.all, false, tl!("From")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n))); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + typing_to + }) + .inner; + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, tl!("OK"), true).clicked() { + widgets::keep_range_ordered(d.from, &mut d.to, false); let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); req.close = true; @@ -1342,10 +1358,13 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) /// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted. fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.set_width(610.0); let t = Tokens::get(ui.ctx()); + preferences::preferences_header(ui, &t); + ui.add_space(10.0); // The settings outgrow a short window: scroll them rather than run off the screen. // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); + let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW - 40.0).max(preferences::PREFERENCES_MIN_SCROLL); // A scroll area starts from the room the dialog offers it, and a modal offers more // each frame for the first few, so with OK outside the scroll the dialog would grow // over those frames. Asking for the full height as the least it may scroll in @@ -1354,9 +1373,17 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin // cards and other full-width rows (#3557). ui.scope(|ui| { ui.spacing_mut().scroll.floating = false; - egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); + ui.spacing_mut().scroll.bar_inner_margin = 14.0; + ui.spacing_mut().scroll.bar_outer_margin = 2.0; + ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25); + egui::ScrollArea::vertical() + .id_salt("preferences-dialog-scroll") + .max_height(max) + .min_scrolled_height(max) + .animated(true) + .show(ui, |ui| preferences::preferences_body(ui, app, &t)); }); - req.close = crate::js_ui::preferences_buttons(ui); + req.close = preferences::preferences_buttons(ui); false } @@ -1370,13 +1397,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); let d = &mut app.number_draft; d.to = d.to.clamp(1, n); - d.from = d.from.clamp(1, d.to); + d.from = d.from.clamp(1, n); + let mut typing_to = false; egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Pages")); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); + typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n))); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); }); ui.end_row(); @@ -1403,18 +1431,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); ui.end_row(); }); - d.to = d.to.max(d.from); + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); ui.add_space(8.0); - let preview = if d.from == d.to { + // `to` can be below `from` while it is being typed. + let preview = if d.to <= d.from { label(d.start) } else { - format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX)))) }; ui.label( egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted), ); - req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + // Ordered here too: the footer's OK can be pressed while the end is still being typed. + req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); true } @@ -1553,6 +1583,91 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool { true } +fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::ghost_button(ui, "x", tl!("Close")).clicked() { + req.close = true; + } + }); + }); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted)); + ui.add_space(10.0); + + ui.horizontal(|ui| { + let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted); + let search_resp = + ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0)); + search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…"))); + if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() { + app.pin_tool_search.clear(); + } + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(6.0); + + let query = app.pin_tool_search.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + let mut toggle = None; + for g in pdfcraft_engine::catalog::TOOL_GROUPS { + let label = tl!(g.label); + let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::>().join(" • ")).unwrap_or_default(); + if !query.is_empty() + && !label.to_lowercase().contains(&query) + && !blurb.to_lowercase().contains(&query) + && !g.id.to_lowercase().contains(&query) + { + continue; + } + + let pinned = app.is_tool_pinned(g.id); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); + let press = theme::Press::track(ui, &resp); + press.wash(ui, rect, 6, false); + let body = rect.translate(press.offset()); + + let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); + crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color); + + let btn_w = 78.0; + let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0)); + let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click()); + let btn_hovered = btn_resp.hovered(); + + let (btn_fill, btn_stroke, text_color, btn_label) = if pinned { + (t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned")) + } else if btn_hovered { + (t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin")) + } else { + (egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin")) + }; + let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") }; + btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}"))); + + ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside); + ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color); + + let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0); + widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w); + widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w); + + if btn_resp.clicked() || resp.clicked() { + toggle = Some(g.id); + } + } + if let Some(id) = toggle { + app.toggle_pin_tool(id); + } + }); + true +} + fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { ui.add_space(16.0); let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); @@ -1569,6 +1684,10 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: req.recover = Some(false); req.close = true; } + } else if dialog == Dialog::PinTool { + if widgets::pill_button(ui, tl!("Done"), true).clicked() { + req.close = true; + } } else if dialog == Dialog::NumberPages { if widgets::pill_button(ui, tl!("OK"), true).clicked() { req.apply_number = true; @@ -1836,6 +1955,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: // Protect / Remove security replace the Properties dialog. let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); if modal_closed || req.close || replaces { + // Only now: the number in Preferences can pass through 0 on its way to the one wanted. + app.recent.truncate(app.recent_limit); app.dialog = None; app.props_draft = None; app.view_draft = None; diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 48ad0cb31c..2b1b35400f 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -1,11 +1,10 @@ //! Preferences › Appearance: the appearance mode (follow the system, light or dark) above a light //! and a dark theme card, each with a small picture of PdfCraft in that theme. -use egui::{Rect, RichText, pos2, vec2}; +use egui::{CornerRadius, Rect, RichText, Stroke, pos2, vec2}; use crate::PdfCraftApp; use crate::theme::{self, ThemeKind, ThemePreference, Tokens}; -use crate::widgets; /// The themes each card offers. One of each today; a new theme is one more row here. pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")]; @@ -82,43 +81,70 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } } -/// One theme card: its title, Active when it is what shows now, a preview of the chosen theme -/// and a radio row per theme. Returns true when the card or one of its radio rows was clicked. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { - let inner = egui::Frame::new() - .fill(t.card) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border })) - .corner_radius(6.0) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - ui.add_space(4.0); - let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); - preview(ui, shown, ui.available_width()); - ui.add_space(4.0); - let mut clicked = false; - for (kind, label) in themes { - // One theme per card today, so it is always the chosen one. - clicked |= ui.add(widgets::radio(*kind == shown, tl!(label))).clicked(); - } - clicked - }); - // The whole card picks its theme, not just the radio row. - let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); - inner.inner || whole.clicked() +/// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. +/// Returns whether the card was clicked and the control's Response. +fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> (bool, egui::Response) { + let mut card_clicked = false; + let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; + let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| { + egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let font = theme::semibold(12.5); + let ranges = crate::dialogs::preferences::find_match_ranges(title, query); + let galley = if ranges.is_empty() { + crate::bidi::layout_ui(ui, title.to_owned(), font, t.text) + } else { + let (job, _) = crate::dialogs::preferences::create_highlight_job(title, &ranges, font, t.text); + ui.painter().layout_job(job) + }; + let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); + ui.painter().galley(title_rect.min, galley, t.text); + if active { + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + ui.add_space(4.0); + let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); + preview(ui, shown, ui.available_width()); + }, + ); + }); + + let resp = scope_res.response; + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); + + if resp.has_focus() { + ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + } + + let resp = theme::hand(resp); + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + if resp.clicked() || key_select { + card_clicked = true; + } + (card_clicked, resp) } /// The Appearance section of Preferences. +#[cfg_attr(not(test), expect(dead_code, reason = "called by unit tests"))] pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); + let mut ctx = crate::dialogs::preferences::PrefSearchContext::new("", false); + preferences_section_with_context(ui, app, t, &mut ctx); +} + +/// The Appearance section of Preferences with live search highlighting and scrolling context. +pub(crate) fn preferences_section_with_context( + ui: &mut egui::Ui, + app: &mut PdfCraftApp, + t: &Tokens, + ctx: &mut crate::dialogs::preferences::PrefSearchContext, +) { + ctx.label(ui, tl_ctx!("theme", "Appearance"), theme::semibold(13.0), t.text); ui.horizontal(|ui| { - let label = ui.label(tl!("Appearance mode")); + let label = ctx.label(ui, tl!("Appearance mode"), theme::regular(13.0), t.text); let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { @@ -132,18 +158,29 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; + let light_title = tl!("Light theme"); + let dark_title = tl!("Dark theme"); + let light_matches = crate::dialogs::preferences::find_match_ranges(light_title, ctx.query).len(); + let dark_matches = crate::dialogs::preferences::find_match_ranges(dark_title, ctx.query).len(); + ctx.total_matches += light_matches + dark_matches; + ui.columns(2, |cols| { let [light, dark] = cols else { return }; - if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) { + let (light_clicked, light_resp) = card(light, t, light_title, light_active, LIGHT_THEMES, ctx.query); + if light_clicked { app.execute(mode_command(ThemePreference::Light)); } - if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) { + ctx.scroll_if_first_match(&light_resp, light_matches); + + let (dark_clicked, dark_resp) = card(dark, t, dark_title, !light_active, DARK_THEMES, ctx.query); + if dark_clicked { app.execute(mode_command(ThemePreference::Dark)); } + ctx.scroll_if_first_match(&dark_resp, dark_matches); }); ui.add_space(4.0); // Acrobat's "Highlight existing fields": shared with the toggle in the form bar. - ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, tl!("Highlight fields"))); + ctx.checkbox(ui, &mut app.view_defaults.highlight_fields, tl!("Highlight fields"), t); } #[cfg(test)] @@ -175,7 +212,6 @@ mod tests { ); h.run(); assert!(h.query_by_label("Light theme").is_some() && h.query_by_label("Dark theme").is_some()); - assert!(h.query_by_label("Light gray").is_some() && h.query_by_label("Dark gray").is_some()); assert_eq!(h.query_all_by_label("Active").count(), 1); if let Ok(path) = std::env::var("PDFCRAFT_APPEARANCE_SHOT") { h.render().unwrap().save(path).unwrap(); @@ -200,7 +236,7 @@ mod tests { assert_eq!(h.state().theme_preference, ThemePreference::System); } - /// Clicking a theme card, or its radio row, picks that theme, not only the mode combo box. + /// Clicking a theme card picks that theme, not only the mode combo box. #[test] fn clicking_a_theme_card_picks_that_theme() { let mut app = PdfCraftApp::new(); @@ -219,10 +255,9 @@ mod tests { app, ); h.run(); - h.get_by_label("Dark gray").click(); + h.get_by_label("Dark theme").click(); h.run(); assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Dark, ThemeKind::Dark)); - // The card's title is not a widget of its own: the click lands on the card. h.get_by_label("Light theme").click(); h.run(); assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Light, ThemeKind::Light)); diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 4702e63f9e..35ce510ae7 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -240,7 +240,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); - ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt")); + ui.add(egui::Slider::new(w, min_width..=crate::comments::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")); ui.end_row(); } if let Some(ends) = e.endings.as_mut() { diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs new file mode 100644 index 0000000000..49609461d8 --- /dev/null +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -0,0 +1,1026 @@ +//! Preferences dialog: card-based settings interface organized into clean, modular cards +//! with segmented pill controls and real-time search filtering. + +use egui::{Color32, CornerRadius, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; + +use crate::PdfCraftApp; +use crate::canvas::{Fit, PageLayout}; +use crate::motion::AnimationEffects; +use crate::scroll_speed::ScrollSpeed; +use crate::side_panels::ToolsSide; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::units::Unit; +use crate::widgets::{self, icons}; + +/// The height the Preferences dialog keeps below its scrolling settings for the buttons row. +pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; + +/// The least height the scrolling settings are given in very small windows. +pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; + +/// A segmented pill control for mutually exclusive choices. +/// +/// Draws a rounded container with options inside. The active option is highlighted +/// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] +pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + segmented_pill_choice_a11y(ui, t, current, &with_a11y) +} + +/// A segmented pill selection bar with separate accessibility labels. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] +pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + segmented_pill_choice_a11y_query(ui, t, current, options, "").0 +} + +/// A segmented pill selection bar with separate accessibility labels and optional query highlighting. +/// Returns whether the selection changed and the Response of the first matching option, if any. +pub fn segmented_pill_choice_a11y_query( + ui: &mut egui::Ui, + t: &Tokens, + current: &mut T, + options: &[(T, &str, &str)], + query: &str, +) -> (bool, Option) { + let mut changed = false; + let mut first_match_resp: Option = None; + egui::Frame::new() + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(14)) + .inner_margin(Margin::symmetric(2, 2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + for &(value, label, a11y_label) in options { + let is_active = *current == value; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = layout_pill_label(ui, label, query, font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); + + if first_match_resp.is_none() && !find_match_ranges(label, query).is_empty() { + first_match_resp = Some(resp.clone()); + } + + if is_active { + ui.painter().rect(rect, CornerRadius::same(12), t.accent, Stroke::NONE, egui::StrokeKind::Inside); + } else { + let press = theme::Press::track(ui, &resp); + let fill = press.fill(Color32::TRANSPARENT, t.hover, t.pressed); + ui.painter().rect(rect, CornerRadius::same(12), fill, Stroke::NONE, egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + if resp.has_focus() { + ui.painter().rect_stroke( + rect.expand(1.0), + CornerRadius::same(13), + Stroke::new(2.0, t.accent_text), + egui::StrokeKind::Outside, + ); + } + + let resp = theme::hand(resp); + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + if (resp.clicked() || key_select) && *current != value { + *current = value; + changed = true; + } + } + }); + }); + (changed, first_match_resp) +} + +/// An independent toggle pill button with optional query highlighting. +/// Returns whether the active state changed and the control's Response. +pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> (bool, egui::Response) { + let mut changed = false; + let is_active = *active; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = layout_pill_label(ui, label, query, font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 20.0, 26.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, is_active, label)); + + let (fill, stroke) = if is_active { + (t.accent, Stroke::NONE) + } else { + let press = theme::Press::track(ui, &resp); + (press.fill(t.field, t.hover, t.pressed), Stroke::new(1.0, t.border)) + }; + ui.painter().rect(rect, CornerRadius::same(13), fill, stroke, egui::StrokeKind::Inside); + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + if resp.has_focus() { + ui.painter().rect_stroke(rect.expand(1.0), CornerRadius::same(14), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + } + + let resp = theme::hand(resp); + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + if resp.clicked() || key_select { + *active = !*active; + changed = true; + } + (changed, resp) +} + +fn layout_pill_label(ui: &egui::Ui, label: &str, query: &str, font: egui::FontId, normal_color: Color32) -> std::sync::Arc { + let ranges = find_match_ranges(label, query); + if ranges.is_empty() { + crate::bidi::layout_ui(ui, label.to_owned(), font, normal_color) + } else { + let (job, _) = create_highlight_job(label, &ranges, font, normal_color); + ui.painter().layout_job(job) + } +} + +/// A standard card container for a Preferences section. +/// Groups related settings with clean margins, subtle border and rounded corners. +pub fn preference_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + icon: Option<&str>, + ctx: &mut PrefSearchContext, + content: impl FnOnce(&mut egui::Ui, &mut PrefSearchContext) -> R, +) -> R { + let card_fill = if t.kind == ThemeKind::Light { Color32::from_rgb(0xF7, 0xF8, 0xFA) } else { Color32::from_rgb(0x24, 0x24, 0x28) }; + egui::Frame::new() + .fill(card_fill) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if let Some(ic) = icon { + ui.add(icons::image(ic, 16.0, t.accent)); + } + ctx.card_title(ui, title, t); + }); + ui.add_space(8.0); + content(ui, ctx) + }) + .inner +} + +/// Folds a character for case-insensitive search, folding dotted/dotless i variants +/// ('I', 'i', 'İ', 'ı') to 'i', Greek sigmas to 'σ', and otherwise following `char::to_lowercase`. +fn fold_search_char(c: char, mut f: impl FnMut(char)) { + match c { + 'I' | 'i' | '\u{130}' | '\u{131}' => f('i'), + 'Σ' | 'σ' | 'ς' => f('σ'), + _ => { + for lc in c.to_lowercase() { + f(lc); + } + } + } +} + +/// Finds non-overlapping (start_byte, end_byte) slices in `text` matching `query` case-insensitively. +/// All byte indices are guaranteed to align to valid char boundaries. +pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { + let q = query.trim(); + if q.is_empty() { + return Vec::new(); + } + let mut q_chars = Vec::new(); + for c in q.chars() { + fold_search_char(c, |ch| q_chars.push(ch)); + } + if q_chars.is_empty() { + return Vec::new(); + } + let char_indices: Vec<(usize, char)> = text.char_indices().collect(); + let mut ranges = Vec::new(); + let mut i = 0; + while i < char_indices.len() { + let mut matched = true; + let mut text_char_count = 0; + let mut q_idx = 0; + + while q_idx < q_chars.len() { + if i + text_char_count >= char_indices.len() { + matched = false; + break; + } + let c = char_indices.get(i + text_char_count).map(|(_, ch)| *ch).unwrap_or('\0'); + let mut c_folded = ['\0'; 4]; + let mut len = 0; + fold_search_char(c, |lc| { + if len < c_folded.len() { + c_folded[len] = lc; + len += 1; + } + }); + if len == 0 || q_idx + len > q_chars.len() || q_chars.get(q_idx..q_idx + len) != Some(&c_folded[..len]) { + matched = false; + break; + } + q_idx += len; + text_char_count += 1; + } + + if matched + && q_idx == q_chars.len() + && text_char_count > 0 + && let Some(start_byte) = char_indices.get(i).map(|(pos, _)| *pos) + { + let end_byte = if let Some((pos, _)) = char_indices.get(i + text_char_count) { *pos } else { text.len() }; + ranges.push((start_byte, end_byte)); + i += text_char_count; + continue; + } + i += 1; + } + ranges +} + +/// Builds an `egui::text::LayoutJob` with yellow background for matching spans. +pub fn create_highlight_job( + text: &str, + ranges: &[(usize, usize)], + normal_font: egui::FontId, + normal_color: Color32, +) -> (egui::text::LayoutJob, usize) { + let mut job = egui::text::LayoutJob::default(); + let normal_format = egui::text::TextFormat { font_id: normal_font.clone(), color: normal_color, ..Default::default() }; + let highlight_format = egui::text::TextFormat { + font_id: egui::FontId::new(normal_font.size, egui::FontFamily::Proportional), + color: Color32::BLACK, + background: Color32::from_rgb(255, 220, 60), + ..Default::default() + }; + + let mut cursor = 0; + for &(start, end) in ranges { + if start > cursor + && let Some(sub) = text.get(cursor..start) + { + job.append(sub, 0.0, normal_format.clone()); + } + if let Some(matched) = text.get(start..end) { + job.append(matched, 0.0, highlight_format.clone()); + } + cursor = end; + } + if cursor < text.len() + && let Some(sub) = text.get(cursor..) + { + job.append(sub, 0.0, normal_format); + } + let count = ranges.len(); + (job, count) +} + +/// Creates a `WidgetText` highlighting `query` in `text` and returning match count. +pub fn highlight_widget_text(text: &str, query: &str, normal_font: egui::FontId, normal_color: Color32) -> (egui::WidgetText, usize) { + let ranges = find_match_ranges(text, query); + if ranges.is_empty() { + (egui::WidgetText::from(egui::RichText::new(text).font(normal_font).color(normal_color)), 0) + } else { + let (job, count) = create_highlight_job(text, &ranges, normal_font, normal_color); + (egui::WidgetText::from(job), count) + } +} + +/// Context for browser-style in-situ text scanning, highlighting, and auto-scrolling in Preferences. +pub(crate) struct PrefSearchContext<'a> { + pub query: &'a str, + pub total_matches: usize, + pub first_match_scrolled: bool, + pub should_scroll: bool, +} + +impl<'a> PrefSearchContext<'a> { + pub fn new(query: &'a str, should_scroll: bool) -> Self { + Self { query, total_matches: 0, first_match_scrolled: false, should_scroll } + } + + pub fn scroll_if_first_match(&mut self, resp: &egui::Response, count: usize) { + if count > 0 && self.should_scroll && !self.first_match_scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + self.first_match_scrolled = true; + } + } + + pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); + self.total_matches += count; + let resp = ui.add(widgets::checkbox(checked, widget_text)); + self.scroll_if_first_match(&resp, count); + resp + } + + pub fn label(&mut self, ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32) -> egui::Response { + let (widget_text, count) = highlight_widget_text(text, self.query, font, color); + self.total_matches += count; + let resp = ui.label(widget_text); + self.scroll_if_first_match(&resp, count); + resp + } + + pub fn label_primary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(13.0), t.text) + } + + pub fn label_secondary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(11.5), t.text_muted) + } + + pub fn card_title(&mut self, ui: &mut egui::Ui, title: &str, t: &Tokens) -> egui::Response { + self.label(ui, title, theme::semibold(13.5), t.text) + } + + pub fn pill_choice(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let mut matches = 0; + for &(_, label) in options { + matches += find_match_ranges(label, self.query).len(); + } + self.total_matches += matches; + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed + } + + pub fn pill_choice_a11y(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + let mut matches = 0; + for &(_, label, _) in options { + matches += find_match_ranges(label, self.query).len(); + } + self.total_matches += matches; + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, options, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed + } + + pub fn toggle_pill(&mut self, ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + let matches = find_match_ranges(label, self.query).len(); + self.total_matches += matches; + let (changed, resp) = toggle_pill_query(ui, t, active, label, self.query); + self.scroll_if_first_match(&resp, matches); + changed + } +} + +/// Whether the interface can draw a language picker's native name. +fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = (ui, text); + true + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) + } +} + +/// The date picker's displayed name. +pub(crate) fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { + match crate::i18n::Lang::from_code(l.code) { + Some(language) => language.picker_name(drawable), + None => l.name, + } +} + +/// Interactive search box widget at the top of Preferences. +fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, matches_count: Option, rect: Rect) { + let stroke = Stroke::new(1.0, t.border); + ui.painter().rect(rect, CornerRadius::same(15), t.field, stroke, egui::StrokeKind::Inside); + + // Magnifying glass icon: 14pt icon centered vertically at (rect.min.x + 16, rect.center().y) + let icon_rect = Rect::from_center_size(pos2(rect.min.x + 16.0, rect.center().y), vec2(14.0, 14.0)); + icons::paint(ui, icon_rect, "search", 14.0, t.text_muted); + + let badge_text = if !query.trim().is_empty() { + let count = matches_count.unwrap_or(0); + Some(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} match", "{n} matches")) + } else { + None + }; + + let mut right_cursor = rect.max.x; + let edit_id = ui.auto_id_with("search_input"); + + if !query.is_empty() { + let clear_rect = Rect::from_center_size(pos2(right_cursor - 14.0, rect.center().y), vec2(16.0, 16.0)); + let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); + clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); + icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); + if clear_resp.clicked() { + query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); + } + right_cursor -= 28.0; + } + + if let Some(ref text) = badge_text { + let font = theme::medium(11.0); + let count = matches_count.unwrap_or(0); + let badge_color = if count > 0 { t.accent_text } else { t.text_muted }; + let galley = crate::bidi::layout_ui(ui, text.clone(), font, badge_color); + let bw = galley.size().x; + let badge_pos = pos2(right_cursor - bw - 4.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(badge_pos, galley, badge_color); + let badge_rect = Rect::from_min_size(badge_pos, vec2(bw, 16.0)); + let badge_resp = ui.interact(badge_rect, ui.auto_id_with("search_match_count"), Sense::hover()); + badge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text.clone())); + right_cursor -= bw + 8.0; + } + + let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(right_cursor.max(rect.min.x + 30.0), rect.max.y)); + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let edit = child_ui.add( + egui::TextEdit::singleline(query) + .id(edit_id) + .hint_text(tl!("Search settings…")) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(edit_rect.width()), + ); + edit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search settings…"))); + + if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); + } +} + +/// Top header for Preferences: Title on the left, search box and match count badge on the right. +pub(crate) fn preferences_header(ui: &mut egui::Ui, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); + + // Top Header: Title on the left, Search bar on the right. + // Allocate the full available width so the search bar aligns flush with the card boxes below. + let full_w = ui.available_width(); + let header_h = 30.0; + let (header_rect, _) = ui.allocate_exact_size(vec2(full_w, header_h), Sense::hover()); + + // Title on the left, vertically centered in the header row: + let title_rect = Rect::from_min_max(header_rect.min, pos2(header_rect.min.x + 240.0, header_rect.max.y)); + let mut title_ui = ui.new_child(egui::UiBuilder::new().max_rect(title_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + title_ui.label(RichText::new(tl!("Preferences")).font(theme::semibold(18.0)).color(t.text)); + + // Search bar on the right, right edge flush with the card box boundary: + let search_w = 230.0; + let search_rect = Rect::from_min_max(pos2(header_rect.max.x - search_w, header_rect.min.y), header_rect.max); + let match_count = (!query.trim().is_empty()).then_some(last_matches); + let before = query.clone(); + preference_search_box_at(ui, t, &mut query, match_count, search_rect); + if query != before { + ui.data_mut(|d| d.insert_temp(search_id, query)); + ui.ctx().request_repaint(); + } +} + +/// The body of the Preferences dialog with card layout and browser-style in-situ text scanning. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); + let last_scrolled_id = egui::Id::unique("pdfcraft_pref_last_scrolled_query"); + let last_scrolled_query: String = ui.data(|d| d.get_temp(last_scrolled_id)).unwrap_or_default(); + + let should_scroll = !query.trim().is_empty() && query != last_scrolled_query; + let mut ctx = PrefSearchContext::new(&query, should_scroll); + + // Card 1: General & Interface + let title1 = tl!("General & Interface"); + preference_card(ui, t, title1, Some("settings"), &mut ctx, |ui, ctx| { + card_general(ui, app, t, ctx); + }); + ui.add_space(12.0); + + // Card 2: Appearance & Theme + let title2 = tl_ctx!("theme", "Appearance"); + preference_card(ui, t, title2, Some("sun"), &mut ctx, |ui, ctx| { + card_appearance(ui, app, t, ctx); + }); + ui.add_space(12.0); + + // Card 3: Documents and View + let title3 = tl!("Documents and view"); + preference_card(ui, t, title3, Some("file-text"), &mut ctx, |ui, ctx| { + card_documents_and_view(ui, app, t, ctx); + }); + ui.add_space(12.0); + + // Card 4: Comment + let title4 = tl!("Comment"); + preference_card(ui, t, title4, Some("message-square-text"), &mut ctx, |ui, ctx| { + card_identity(ui, app, t, ctx); + }); + ui.add_space(12.0); + + // Card 5: Fill & Sign + let title5 = tl!("Fill & Sign"); + preference_card(ui, t, title5, Some("pen-tool"), &mut ctx, |ui, ctx| { + card_fill_and_sign(ui, app, t, ctx); + }); + ui.add_space(12.0); + + // Card 6: JavaScript + let title6 = "JavaScript"; + preference_card(ui, t, title6, Some("code-xml"), &mut ctx, |ui, ctx| { + card_javascript(ui, app, t, ctx); + }); + ui.add_space(12.0); + + if ctx.first_match_scrolled { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, query.clone())); + } else if query.trim().is_empty() { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, String::new())); + } + + if ctx.total_matches != last_matches { + ui.data_mut(|d| d.insert_temp(matches_id, ctx.total_matches)); + ui.ctx().request_repaint(); + } +} + +/// Content for Card 1: General & Interface +fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Interface language"), t); + let (selected, picker) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let selected = crate::i18n::Lang::from_code(&app.language) + .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); + let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) + .into_iter() + .map(|language| (language, language.picker_name(drawable))) + .collect::>(); + (selected, picker) + }; + let before = app.language.clone(); + craft_ui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .width(160.0) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { + ui.close(); + } + for (language, name) in &picker { + if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + if app.language != before { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); + } + + ui.add_space(16.0); + use crate::ui_scale; + let label_size = ctx.label_primary(ui, tl!("Interface size"), t); + let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); + let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); + let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); + let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); + craft_ui::ComboBox::from_id_salt("interface-size") + .selected_text(selected) + .width(130.0) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { + ui.close(); + } + for p in ui_scale::PRESETS { + let factor = f32::from(p) / 100.0; + if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { + app.ui_scale = Some(factor); + ui.close(); + } + } + }) + .response + .labelled_by(label_size.id); + }); + ctx.label_secondary(ui, tl!("Auto follows the system's text size. Pages always render at the display's full resolution."), t); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Tools panel side"), t); + let right = app.side_panels.tools_on_right(); + let mut side = if right { ToolsSide::Right } else { ToolsSide::Left }; + if ctx.pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { + app.side_panels.tools_side = side; + } + + ui.add_space(16.0); + ctx.label_primary(ui, tl!("Navigation rail side"), t); + let rail_right = app.side_panels.rail_on_right(); + let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; + let left_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Left")); + let right_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Right")); + if ctx.pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) { + app.side_panels.rail_side = rside; + } + }); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Animation effects"), t); + ctx.pill_choice( + ui, + t, + &mut app.animation_effects, + &[ + (AnimationEffects::System, tl_ctx!("animation effects", AnimationEffects::System.label())), + (AnimationEffects::On, tl_ctx!("animation effects", AnimationEffects::On.label())), + (AnimationEffects::Off, tl_ctx!("animation effects", AnimationEffects::Off.label())), + ], + ); + }); + ui.add_space(6.0); + + #[cfg(not(target_arch = "wasm32"))] + ctx.checkbox(ui, &mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"), t); + #[cfg(not(target_arch = "wasm32"))] + ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t); + #[cfg(not(target_arch = "wasm32"))] + ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t); + ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t); + + #[cfg(not(target_arch = "wasm32"))] + { + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Recent files to keep"), t); + ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); + }); + ctx.label_secondary(ui, tl!("Lowering this forgets the oldest files. At 0 there is no list."), t); + } + + if app.window_controls.is_some() { + ctx.checkbox(ui, &mut app.system_title_bar, tl!("Use system title bar"), t).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." + )); + } +} + +/// Content for Card 2: Appearance & Theme +fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + if ctx.checkbox(ui, &mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"), t).changed() { + ui.ctx().request_repaint(); + } + ui.add_space(4.0); + crate::appearance::preferences_section_with_context(ui, app, t, ctx); + ui.add_space(8.0); + reading_colours_section(ui, app, t, ctx); +} + +/// Content for Card 3: Documents and View +fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Default workspace mode"), t); + ctx.pill_choice( + ui, + t, + &mut app.default_mode, + &[ + (crate::Mode::AllTools, tl!("All tools")), + (crate::Mode::Read, tl!("Read")), + (crate::Mode::Edit, tl!("Edit")), + (crate::Mode::Convert, tl!("Convert")), + (crate::Mode::Sign, tl!("E-Sign")), + ], + ); + }); + ctx.label_secondary(ui, tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session."), t); + ui.add_space(6.0); + + let defaults = &mut app.view_defaults; + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Default page display"), t); + let mut layout_choice = defaults.layout; + if ctx.pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { + defaults.layout = layout_choice; + } + let mut continuous = defaults.continuous; + if ctx.toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { + defaults.continuous = continuous; + } + }); + ctx.checkbox(ui, &mut defaults.show_page_gaps, tl!("Show gaps between pages"), t); + ctx.label_secondary(ui, tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged."), t); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Default zoom"), t); + ctx.pill_choice( + ui, + t, + &mut defaults.fit, + &[(Fit::Width, tl!("Fit to width")), (Fit::Page, tl!("Zoom to page level")), (Fit::None, tl!("Custom"))], + ); + let mut percent = defaults.zoom * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { + (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); + } + }); + ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); + ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Units"), t); + ctx.pill_choice( + ui, + t, + &mut app.units, + &[ + (Unit::Inches, Unit::Inches.label()), + (Unit::Millimetres, Unit::Millimetres.label()), + (Unit::Centimetres, Unit::Centimetres.label()), + (Unit::Points, Unit::Points.label()), + ], + ); + }); + ctx.label_secondary(ui, tl!("For page sizes and margins."), t); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t); + ctx.pill_choice( + ui, + t, + &mut app.scroll_speed, + &[ + (ScrollSpeed::Slow, tl_ctx!("scroll speed", ScrollSpeed::Slow.label())), + (ScrollSpeed::Normal, tl_ctx!("scroll speed", ScrollSpeed::Normal.label())), + (ScrollSpeed::Fast, tl_ctx!("scroll speed", ScrollSpeed::Fast.label())), + ], + ); + }); +} + +/// Content for Card 4: Identity +fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Name on new comments"), t); + ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(240.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); + }); + ctx.checkbox(ui, &mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"), t); +} + +/// Content for Card 5: Fill & Sign +fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.checkbox(ui, &mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"), t); + ctx.label_secondary(ui, tl!("Text, marks and signatures become part of the page. Other comments stay editable."), t); + ui.add_space(8.0); + date_format(ui, app, t, ctx); +} + +/// Content for Card 6: JavaScript +fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + let mut on = app.session.javascript(); + if ctx.checkbox(ui, &mut on, tl!("Enable Acrobat JavaScript"), t).changed() { + app.session.set_javascript(on); + } + ctx.label_secondary( + ui, + tl!( + "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." + ), + t, + ); +} + +/// Preferences dialog buttons: OK on the right. Returns true to close. +pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { + ui.add_space(10.0); + let mut close = false; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + close = true; + } + }); + }); + close +} + +/// Fill & Sign date pattern and date language picker. +fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; + ctx.label(ui, tl!("Date format"), theme::semibold(12.5), t.text); + let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default()); + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Fill & Sign dates"), t); + let current = app.session.date_format().to_string(); + let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; + craft_ui::ComboBox::from_id_salt("date-format") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + for f in DATE_FORMATS { + if ui.selectable_label(current == f, sample(app, f)).clicked() && app.session.set_date_format(f).is_ok() { + app.date_format_draft = None; + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Pattern"), t); + let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string()); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id); + if edit.changed() { + let _ = app.session.set_date_format(&text); + app.date_format_draft = Some(text.clone()); + } + if !edit.has_focus() && pdfcraft_engine::dates::check_date_format(&text).is_ok() { + app.date_format_draft = None; + } + match app.date_text(Some(&text)) { + Ok(today) => { + let today_str = crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)]); + ctx.label_secondary(ui, &today_str, t); + } + Err(e) => { + ctx.label(ui, &e, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); + } + }; + }); + let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); + if !bad.is_empty() { + let bad_msg = crate::i18n::fmt( + tl!("{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."), + &[("chars", &bad)], + ); + ctx.label(ui, &bad_msg, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); + } + ui.horizontal(|ui| { + let label = ctx.label_primary(ui, tl!("Language"), t); + let (follow, selected, current, names) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let interface = crate::i18n::current().picker_name(drawable); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES + .iter() + .find(|l| current.as_deref() == Some(l.code)) + .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); + let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); + (follow, selected, current, names) + }; + craft_ui::ComboBox::from_id_salt("date-language") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { + ui.close(); + } + for (l, name) in &names { + if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() + { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + let date_help = crate::i18n::fmt( + tl!( + "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." + ), + &[("escape", "\\")], + ); + ctx.label_secondary(ui, &date_help, t); +} + +/// Reading colours settings: presets, preview and custom replacement options. +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.label(ui, tl!("Reading colours"), theme::semibold(13.0), t.text); + let mode = app.reading_colors.mode_name(); + ui.horizontal_wrapped(|ui| { + for (name, label, colors) in [ + ("off", "Off", pdfcraft_render::ReadingColors::default()), + ("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE), + ("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK), + ("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK), + ("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK), + ] { + let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams { + text: [0, 0, 0], + background: [255, 255, 255], + only_black: false, + line_art: true, + }); + ui.vertical(|ui| { + ui.set_width(100.0); + let sample = RichText::new("Aa").size(20.0).color(Color32::from_rgb(params.text[0], params.text[1], params.text[2])); + let button = egui::Button::new(sample) + .fill(Color32::from_rgb(params.background[0], params.background[1], params.background[2])) + .min_size(vec2(90.0, 40.0)) + .stroke(Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border })); + let response = ui.add(button); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label))); + if response.clicked() { + app.set_reading_colors(colors); + } + ctx.label(ui, tl!(label), theme::regular(12.0), t.text); + }); + } + }); + ui.add_space(4.0); + if let Some(params) = app.reading_colors.params { + let text = Color32::from_rgb(params.text[0], params.text[1], params.text[2]); + let background = Color32::from_rgb(params.background[0], params.background[1], params.background[2]); + egui::Frame::new().fill(background).inner_margin(12.0).corner_radius(CornerRadius::same(6)).show(ui, |ui| { + ui.set_min_width((ui.available_width() - 24.0).max(0.0)); + ctx.label(ui, tl!("Reading colours"), theme::semibold(16.0), text); + ctx.label(ui, tl!("Colours are remapped while rendering; the document itself never changes."), theme::regular(13.0), text); + }); + } else { + ctx.label_primary(ui, tl!("Original"), t); + } + egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| { + let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE }; + let mut changed = false; + if let Some(params) = colors.params.as_mut() { + ui.horizontal(|ui| { + let text = ctx.label_primary(ui, tl!("Text"), t); + changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); + let background = ctx.label_primary(ui, tl!("Page background"), t); + changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); + }); + changed |= ctx.checkbox(ui, &mut params.only_black, tl!("Only change the colour of black text or line art"), t).changed(); + changed |= ctx.checkbox(ui, &mut params.line_art, tl!("Change the colour of line art as well as text"), t).changed(); + } + if changed { + app.set_reading_colors(colors); + } + }); + ctx.label_secondary(ui, tl!("Colours are remapped while rendering; the document itself never changes."), t); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_match_ranges_case_insensitive() { + let text = "Hide hover previews for highlights without comment text"; + let ranges = find_match_ranges(text, "hide hover"); + assert_eq!(ranges, vec![(0, 10)]); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("Hide hover")); + + let text2 = "Highlight fields and highlight text"; + let ranges2 = find_match_ranges(text2, "highlight"); + assert_eq!(ranges2.len(), 2); + assert_eq!(text2.get(ranges2[0].0..ranges2[0].1), Some("Highlight")); + assert_eq!(text2.get(ranges2[1].0..ranges2[1].1), Some("highlight")); + + let ranges3 = find_match_ranges(text, "not found"); + assert!(ranges3.is_empty()); + + let ranges4 = find_match_ranges(text, ""); + assert!(ranges4.is_empty()); + } + + #[test] + fn test_find_match_ranges_turkish_i() { + let text = "Doldur ve İmzala"; + let ranges = find_match_ranges(text, "imzala"); + assert_eq!(ranges.len(), 1); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("İmzala")); + + let ranges2 = find_match_ranges(text, "İMZALA"); + assert_eq!(ranges2.len(), 1); + assert_eq!(text.get(ranges2[0].0..ranges2[0].1), Some("İmzala")); + + let text2 = "Doldur ve imzala"; + let ranges3 = find_match_ranges(text2, "İmzala"); + assert_eq!(ranges3.len(), 1); + assert_eq!(text2.get(ranges3[0].0..ranges3[0].1), Some("imzala")); + + let ranges4 = find_match_ranges(text, "ımzala"); + assert_eq!(ranges4.len(), 1); + assert_eq!(text.get(ranges4[0].0..ranges4[0].1), Some("İmzala")); + } +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8def569f58..b810e2abe5 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -31,7 +31,7 @@ fn new_text_typed(v: &crate::DocView) -> bool { /// is a deleted annotation's page's annotations before the delete (empty for other edits). fn select_after_edit( view: &mut crate::DocView, - edit: Edit, + edit: &Edit, info: &pdfcraft_render::DocInfo, annots_before: &[pdfcraft_render::Annotation], quick_tool: crate::QuickTool, @@ -39,21 +39,24 @@ fn select_after_edit( match edit { Edit::MovePages { pages, to } => { let n = pages.iter().collect::>().len(); - let to = to.min(info.pages.len() - n); + let to = (*to).min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } Edit::DuplicatePages { pages, at: Some(at) } => { let n = pages.iter().collect::>().len(); - let at = at.min(info.pages.len().saturating_sub(n)); + let at = (*at).min(info.pages.len().saturating_sub(n)); view.select_pages(&(at..at + n).collect::>()); } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), + Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]), Edit::DeletePages { .. } => view.select_pages(&[]), // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. } | pdfcraft_engine::Shape::FreehandHighlight { .. }) + && matches!( + quick_tool, + crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) + ) => { view.comments.selected = None; } @@ -71,7 +74,7 @@ fn select_after_edit( view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)), + Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), _ => {} } } @@ -112,6 +115,25 @@ impl PdfCraftApp { pub fn apply_edit(&mut self, edit: Edit) -> bool { let Some((i, id)) = self.active_ids() else { return false }; let label = edit.label(); + let target_arrange_image = match &edit { + Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => { + let doc = self.session.get(id); + let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned()); + let ambiguous = doc.is_some_and(|d| { + if let Some(t) = &target { + d.page_images(*page) + .iter() + .filter(|img| img.name == t.name && img.object == t.object && rect_matches(img.rect, t.rect)) + .count() + > 1 + } else { + false + } + }); + target.map(|t| (t, ambiguous)) + } + _ => None, + }; let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) { (Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some()) } else { @@ -166,7 +188,11 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - select_after_edit(view, edit, info, &annots_before, self.quick_tool); + select_after_edit(view, &edit, info, &annots_before, self.quick_tool); + if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) { + let page_images = doc.page_images(*page); + re_resolve_image_selection(view, *page, &target, ambiguous_before, &page_images); + } if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } @@ -187,6 +213,9 @@ impl PdfCraftApp { if let Some(view) = self.views.get_mut(i) { // A drawing whose annotation wasn't added must not stay painted. view.comments.drawings_refused(); + if matches!(&edit, Edit::EditPageImage { change, .. } if !matches!(change, pdfcraft_engine::ImageEdit::Arrange(_))) { + view.image_selection = None; + } // A paragraph that couldn't be applied opens again with its typing, instead of // the box the click moved to. if let Edit::EditTextBlock { page, block, .. } = &edit @@ -287,13 +316,34 @@ impl PdfCraftApp { if !self.close_text_editors(i, id) { return; } + let target_image = self.views.get(i).and_then(|v| v.image_selection.as_ref()).and_then(|s| { + let doc = self.session.get(id)?; + let images = doc.page_images(s.page); + let target = images.get(s.index).cloned()?; + let ambiguous = + images.iter().filter(|img| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)).count() > 1; + Some((s.page, target, ambiguous)) + }); + let had_selection = self.views.get(i).and_then(|v| v.image_selection.as_ref()).is_some(); let result = if undo { self.session.undo(id) } else { self.session.redo(id) }; match result { Ok(label) => { - if let Some(doc) = self.session.get(id) { - self.views[i].document_changed(&doc.info); + if let Some(doc) = self.session.get(id) + && let Some(view) = self.views.get_mut(i) + { + view.document_changed(&doc.info); + view.picked_image = None; + if let Some((page, target, ambiguous_before)) = target_image { + let page_images = doc.page_images(page); + re_resolve_image_selection(view, page, &target, ambiguous_before, &page_images); + } else if had_selection { + view.image_selection = None; + } + } + if let Some(view) = self.views.get_mut(i) { + view.comments.selected = None; + view.comments.history_stepped(); } - self.views[i].comments.history_stepped(); let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); @@ -783,6 +833,48 @@ impl PdfCraftApp { } } +fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3) +} + +fn re_resolve_image_selection( + view: &mut crate::DocView, + page: usize, + target: &pdfcraft_engine::PageImage, + ambiguous_before: bool, + page_images: &[pdfcraft_engine::PageImage], +) { + let exact_matches: Vec = page_images + .iter() + .enumerate() + .filter(|(_, img)| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)) + .map(|(idx, _)| idx) + .collect(); + let new_idx = if !ambiguous_before && exact_matches.len() == 1 { + exact_matches.first().copied() + } else if !ambiguous_before && exact_matches.is_empty() { + let name_obj_matches: Vec = + page_images.iter().enumerate().filter(|(_, img)| img.name == target.name && img.object == target.object).map(|(idx, _)| idx).collect(); + if name_obj_matches.len() == 1 { + name_obj_matches.first().copied() + } else if name_obj_matches.is_empty() { + let name_matches: Vec = page_images.iter().enumerate().filter(|(_, img)| img.name == target.name).map(|(idx, _)| idx).collect(); + if name_matches.len() == 1 { name_matches.first().copied() } else { None } + } else { + None + } + } else { + None + }; + if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == page) { + if let Some(idx) = new_idx { + s.index = idx; + } else { + view.image_selection = None; + } + } +} + /// The single page a content edit changes (`None` for other edits). Like comment edits, /// these leave every other page's bytes, geometry and text alone, so their rasters stay valid. fn content_page(edit: &Edit) -> Option { diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 390b059228..4f5ac9deba 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -20,6 +20,57 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre } } +/// Render a single tool card with the standard lift, styling, icon, title, description, and action. +/// If `allow_unpin` is true, an unpin button is always accessible in the top right corner. +fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> { + let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); + let press = theme::Press::track(ui, &resp); + card_lift(ui, t, rect, press); + let fill = press.fill(t.card, t.hover, t.pressed); + ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); + let body = rect.translate(press.offset()); + let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); + icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color); + + let mut unpin_clicked = false; + if allow_unpin { + let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0)); + let unpin_resp = ui.interact(unpin_rect, ui.scope_id().with(("unpin_card_btn", g.id)), Sense::click()); + unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Unpin tool"))); + if unpin_resp.hovered() { + ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed); + } + icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon); + if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() { + unpin_clicked = true; + } + } + + let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text); + title_job.wrap = egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() }; + let title = ui.fonts_mut(|f| f.layout_job(title_job)); + let title_elided = title.elided; + ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text); + let blurb = g.sections.first().map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · ")).unwrap_or_default(); + // Fixed-height cards must leave room for their action in every language. + let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0); + job.wrap.max_rows = 2; + let galley = ui.fonts_mut(|f| f.layout_job(job)); + let blurb_elided = galley.elided; + ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted); + ui.painter().text(body.left_bottom() + vec2(14.0, -14.0), Align2::LEFT_CENTER, tl!("Use now"), theme::medium(12.0), t.accent_text); + let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp }; + if unpin_clicked { + Some(g.id) + } else { + if theme::hand(resp).clicked() { + app.select_tool(g.id); + } + None + } +} + /// Draw the Home page with the welcome text, community card and recent documents. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -73,44 +124,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 14.0); for id in RECOMMENDED { - let Some(g) = catalog::group(id) else { continue }; - let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); - let press = theme::Press::track(ui, &resp); - card_lift(ui, &t, rect, press); - let fill = press.fill(t.card, t.hover, t.pressed); - ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); - let body = rect.translate(press.offset()); - let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); - icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color); - let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text); - title_job.wrap = - egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() }; - let title = ui.fonts_mut(|f| f.layout_job(title_job)); - let title_elided = title.elided; - ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text); - let blurb = g - .sections - .first() - .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · ")) - .unwrap_or_default(); - // Fixed-height cards must leave room for their action in every language. - let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0); - job.wrap.max_rows = 2; - let galley = ui.fonts_mut(|f| f.layout_job(job)); - let blurb_elided = galley.elided; - ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted); - ui.painter().text( - body.left_bottom() + vec2(14.0, -14.0), - Align2::LEFT_CENTER, - tl!("Use now"), - theme::medium(12.0), - t.accent_text, - ); - let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp }; - if theme::hand(resp).clicked() { - app.select_tool(g.id); + if app.is_tool_pinned(id) { + continue; } + let Some(g) = catalog::group(id) else { continue }; + tool_card(app, ui, &t, g, false); } let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file"))); @@ -132,31 +150,64 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); + ui.add_space(26.0); + let mut unpin_tool = None; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() { + app.dialog = Some(crate::Dialog::PinTool); + } + }); + }); + ui.add_space(4.0); + if app.pinned_tools.is_empty() { + ui.label(egui::RichText::new(tl!("Pin your favorite tools here for quick access.")).color(t.text_muted)); + } else { + ui.add_space(6.0); + let pinned = app.pinned_tools.clone(); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 14.0); + for id in &pinned { + let Some(g) = catalog::group(id) else { continue }; + if let Some(to_unpin) = tool_card(app, ui, &t, g, true) { + unpin_tool = Some(to_unpin); + } + } + }); + } + if let Some(id) = unpin_tool { + app.unpin_tool(id); + } + #[cfg(not(target_arch = "wasm32"))] { ui.add_space(26.0); crate::folders_ui::section(app, ui); } - ui.add_space(26.0); let mut clear = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); - if !app.recent.is_empty() { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); - clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); - }); + // Preferences can turn the list off (#5572). + if app.recent_limit > 0 { + ui.add_space(26.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); + if !app.recent.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); + clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); + }); + } + }); + ui.add_space(8.0); + if app.recent.is_empty() { + ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted)); } - }); - ui.add_space(8.0); - if app.recent.is_empty() { - ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted)); } let mut open = None; let mut remove = None; - for r in &app.recent { + for r in app.recent.iter().take(app.recent_limit) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); let press = theme::Press::track(ui, &resp); @@ -229,6 +280,41 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + fn review_home_harness() -> egui_kittest::Harness<'static, PdfCraftApp> { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = PdfCraftApp::new(); + app.pinned_tools = vec!["edit".into()]; + let mut fonts = false; + egui_kittest::Harness::builder().with_size(vec2(1500.0, 1200.0)).build_ui_state( + move |ui, app| { + if !fonts { + ui.ctx().set_fonts(theme::font_definitions()); + fonts = true; + } + show(app, ui); + }, + app, + ) + } + + #[test] + fn review_pinned_recommended_tools_appear_once() { + use egui_kittest::kittest::Queryable; + let mut h = review_home_harness(); + h.run_steps(3); + assert_eq!(h.get_all_by_label("Edit a PDF").count(), 1); + } + + #[test] + fn review_a_pinned_tool_can_be_unpinned_without_hover() { + use egui_kittest::kittest::Queryable; + let mut h = review_home_harness(); + h.run_steps(3); + h.get_by_label("Unpin tool").click(); + h.run_steps(2); + assert!(h.state().pinned_tools.is_empty()); + } + #[test] fn translated_card_description_leaves_room_for_the_action() { let de = crate::i18n::Lang::from_code("de").unwrap(); diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs new file mode 100644 index 0000000000..f6a0c14c7f --- /dev/null +++ b/crates/ui-egui/src/hotbar.rs @@ -0,0 +1,372 @@ +//! A movable, persistent set of shortcuts to the shared command registry. +use egui::{Rect, Sense, vec2}; +use pdfcraft_engine::commands::{COMMANDS, command}; +use serde::{Deserialize, Serialize}; + +use crate::{PdfCraftApp, icons, theme::Tokens}; + +/// Persistent state and UI state for the customizable document hotbar. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Hotbar { + pub tools: Vec, + /// Position as a fraction of the available travel, so resizing keeps the bar visible. + pub position: [f32; 2], + pub vertical: bool, + pub button_size: f32, + pub dock: Option, + pub full_side: bool, + #[serde(skip)] + search: String, + #[serde(skip)] + picker_open: bool, +} + +impl Default for Hotbar { + /// Create a default empty horizontal floating hotbar. + fn default() -> Self { + Self { + tools: Vec::new(), + position: [0.5, 0.0], + vertical: false, + button_size: 32.0, + dock: None, + full_side: false, + search: String::new(), + picker_open: false, + } + } +} + +impl Hotbar { + /// Clamp button size and normalized position coordinates, drop unknown docked sides, + /// and deduplicate/filter tool command identifiers against the command registry. + pub fn normalize(&mut self) { + self.button_size = if self.button_size.is_finite() { self.button_size.clamp(24.0, 64.0) } else { 32.0 }; + if self.dock.as_deref().is_some_and(|side| !matches!(side, "left" | "right" | "top" | "bottom")) { + self.dock = None; + } + let mut seen = std::collections::HashSet::new(); + self.tools.retain(|id| command(id).is_some() && seen.insert(id.clone())); + for coordinate in &mut self.position { + *coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 }; + } + } + + /// Parse and validate a comma-separated list of command identifiers for the hotbar. + pub fn set_tools(&mut self, value: &str) -> Result<(), String> { + let tools: Vec = value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(); + if let Some(id) = tools.iter().find(|id| command(id).is_none()) { + return Err(format!("Unknown hotbar command: {id}")); + } + self.tools = tools; + self.normalize(); + Ok(()) + } + + /// Dock the hotbar to `"left"`, `"right"`, `"top"`, or `"bottom"`, or undock with `"float"`. + pub fn place(&mut self, side: &str) -> Result<(), String> { + if side == "float" { + self.dock = None; + return Ok(()); + } + let (position, vertical) = match side { + "left" => ([0.0, 0.5], true), + "right" => ([1.0, 0.5], true), + "top" => ([0.5, 0.0], false), + "bottom" => ([0.5, 1.0], false), + _ => return Err("hotbar-side must be float, left, right, top or bottom".into()), + }; + self.dock = Some(side.to_owned()); + self.position = position; + self.vertical = vertical; + Ok(()) + } +} + +/// Render the hotbar settings gear button and open the tool picker when clicked. +fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { + let settings = ui + .push_id("hotbar-settings", |ui| { + icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools")) + }) + .inner; + if settings.clicked() { + *picker_open = true; + } +} + +/// Whether `spec` matches the hotbar picker search query across the translated label, canonical +/// English label, or command identifier (case-insensitively). +fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) -> bool { + let search = query.to_lowercase(); + tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search) +} + +/// Render the configured tool shortcut buttons and their removal context menus. +fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { + for (index, id) in tools.iter().enumerate() { + let Some(spec) = command(id) else { continue }; + let active = crate::comments::CommentTool::from_command(id).is_some_and(|tool| app.quick_tool == crate::QuickTool::Comment(tool)); + let button = ui.add_enabled_ui(app.command_enabled(spec), |ui| icons::button(ui, spec.icon, cell, active, tl!(spec.label))).inner; + if button.clicked() { + *run = Some(spec.id); + } + button.context_menu(|ui| { + if ui.button(tl!("Remove from hotbar")).clicked() { + *remove = Some(index); + ui.close(); + } + }); + } +} + +/// Draw the floating or side-docked hotbar and its tool picker window inside `area`. +pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { + let mut bar = std::mem::take(&mut app.hotbar); + let t = Tokens::get(ctx); + let id = egui::Id::unique("custom-hotbar"); + let full_side = bar.full_side && bar.dock.is_some(); + let bounds = if full_side { area } else { area.shrink(10.0) }; + if let Some(side) = bar.dock.clone() { + let _ = bar.place(&side); + } + let cell = bar.button_size; + let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(90.0, 44.0)); + if full_side { + size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) }; + } + let travel = (bounds.size() - size).max(vec2(0.0, 0.0)); + let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]); + let mut run = None; + let mut picker_open = bar.picker_open; + let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| { + let frame = if full_side { + egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6) + } else { + egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6) + }; + let frame_resp = frame.show(ui, |ui| { + if full_side { + ui.set_min_size((size - vec2(12.0, 12.0)).max(vec2(0.0, 0.0))); + } + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + if bar.vertical { + ui.set_width(cell); + } else { + ui.set_height(cell); + } + let mut remove = None; + if full_side { + if bar.vertical { + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_height().max(cell); + egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } else { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_width().max(cell); + egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } + } else { + let layout = + if bar.vertical { egui::Layout::top_down(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, |ui| { + let grip_size = if bar.vertical { vec2(cell, 18.0) } else { vec2(18.0, cell) }; + let (grip_rect, grip) = ui.allocate_exact_size(grip_size, Sense::drag()); + for x in [-3.0, 3.0] { + for y in [-6.0, 0.0, 6.0] { + let offset = if bar.vertical { vec2(y, x) } else { vec2(x, y) }; + ui.painter().circle_filled(grip_rect.center() + offset, 1.5, t.text_faint); + } + } + let drag_label = tl!("Drag hotbar"); + let grip = grip.on_hover_text(drag_label).on_hover_cursor(egui::CursorIcon::Grab); + grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label)); + if grip.dragged() && bar.dock.is_none() { + let delta = grip.drag_delta(); + bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0); + bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0); + } + let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) }; + let scroll = if bar.vertical { + egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max) + } else { + egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max) + }; + scroll.auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + settings_button(ui, cell, &mut picker_open); + }); + } + if let Some(index) = remove { + bar.tools.remove(index); + } + }); + if full_side { + let r = frame_resp.response.rect; + let divider = egui::Stroke::new(1.0, t.divider); + match bar.dock.as_deref() { + Some("left") => { + ui.painter().vline(r.right() - 0.5, r.top()..=r.bottom(), divider); + } + Some("right") => { + ui.painter().vline(r.left() + 0.5, r.top()..=r.bottom(), divider); + } + Some("top") => { + ui.painter().hline(r.left()..=r.right(), r.bottom() - 0.5, divider); + } + Some("bottom") => { + ui.painter().hline(r.left()..=r.right(), r.top() + 0.5, divider); + } + _ => {} + } + } + }); + let picker_id = egui::Id::unique("hotbar-picker"); + // A remembered collapsed state can survive disabling the collapse affordance. + let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true); + expanded.set_open(true); + expanded.store(ctx); + let screen = ctx.content_rect().shrink(12.0); + let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 }; + let picker_pos = egui::pos2( + picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())), + pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())), + ); + egui::Window::new(tl!("Hotbar tools")) + .id(picker_id) + .order(egui::Order::Foreground) + .open(&mut picker_open) + .collapsible(false) + .resizable(false) + .fixed_pos(picker_pos) + .max_height((screen.height() - 24.0).max(180.0)) + .vscroll(true) + .show(ctx, |ui| { + ui.set_min_width(260.0); + ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px")); + ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout"))); + if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() { + let _ = bar.place("float"); + } + ui.label(tl!("Stick to a side:")); + ui.horizontal(|ui| { + for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] { + if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() { + let _ = bar.place(side); + } + } + }); + let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable."); + ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) + .on_hover_text(strip_hint) + .on_disabled_hover_text(strip_hint); + if ui.button(tl!("Close menu")).clicked() { + ui.close(); + } + ui.separator(); + ui.label(tl!("Add tools to hotbar")); + ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for spec in COMMANDS { + if !matches_search(spec, &bar.search) { + continue; + } + let mut selected = bar.tools.iter().any(|id| id == spec.id); + if ui.add(crate::widgets::checkbox(&mut selected, tl!(spec.label))).changed() { + if selected { + bar.tools.push(spec.id.to_owned()); + } else { + bar.tools.retain(|id| id != spec.id); + } + } + } + }); + ui.separator(); + }); + ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size())); + bar.picker_open = picker_open; + app.hotbar = bar; + if let Some(id) = run { + app.execute(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hotbar_validation_and_placement() { + let mut bar = Hotbar::default(); + assert!(bar.tools.is_empty()); + bar.set_tools("comment.ink,comment.freehand_highlight,comment.ink").unwrap(); + assert_eq!(bar.tools.len(), 2); + assert!(bar.set_tools("does.not.exist").is_err()); + assert_eq!(bar.tools.len(), 2); + bar.place("right").unwrap(); + assert!(bar.vertical); + assert_eq!(bar.position, [1.0, 0.5]); + bar.place("bottom").unwrap(); + assert!(!bar.vertical); + assert_eq!(bar.position, [0.5, 1.0]); + assert_eq!(bar.dock.as_deref(), Some("bottom")); + bar.place("float").unwrap(); + assert!(bar.dock.is_none()); + } + + #[test] + fn hotbar_preferences_round_trip() { + let mut app = PdfCraftApp::new(); + app.set_option("hotbar-tools", "comment.freehand_highlight,comment.ink").unwrap(); + app.set_option("hotbar-side", "left").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + app.set_option("hotbar-full-side", "true").unwrap(); + assert!(app.set_option("hotbar-size", "NaN").is_err()); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(restored.hotbar.tools, app.hotbar.tools); + assert_eq!(restored.hotbar.position, [0.0, 0.5]); + assert!(restored.hotbar.vertical); + assert_eq!(restored.hotbar.button_size, 48.0); + assert!(restored.hotbar.full_side); + assert_eq!(restored.hotbar.dock.as_deref(), Some("left")); + } + + #[test] + fn hotbar_restore_filters_unknown_tools_and_offscreen_positions() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"hotbar":{"tools":["invalid","comment.ink","comment.ink"],"position":[-10,999]}}"#); + assert_eq!(app.hotbar.tools, ["comment.ink"]); + assert_eq!(app.hotbar.position, [0.0, 1.0]); + } + + #[test] + fn hotbar_search_matches_translated_and_canonical_labels() { + let ink = command("comment.ink").unwrap(); + crate::i18n::set_current(crate::i18n::lang_from_tag("fr").unwrap()); + assert_eq!(tl!(ink.label), "Dessiner à main levée"); + assert!(matches_search(ink, "dessiner"), "translated French label must match"); + assert!(matches_search(ink, "freehand"), "canonical English label must still match"); + assert!(matches_search(ink, "comment.ink"), "command ID must still match"); + assert!(!matches_search(ink, "introuvable")); + crate::i18n::set_current(crate::i18n::Lang::EN); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3f478b637f..3ac16d622c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -16,7 +16,7 @@ //! placeholders, plural forms) pick it up from the registry. //! //! # Looking strings up -//! - [`tl!`](crate::tl) / [`t`]: a plain string in the current language. [`tr`]: in a given one. +//! - `tl!` / [`t`]: a plain string in the current language. [`tr`]: in a given one. //! - [`tr_ctx`] (`tl_ctx!` in UI code): when one English word needs different translations. //! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items), so a //! translation survives rewording of the English text and can differ per command. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6e06c424df..1b08929307 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -20,6 +20,10 @@ # reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. # Spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu القائمة File ملف Edit تحرير @@ -2792,6 +2796,25 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Clear search مسح البحث + Pin تثبيت + Pin a tool… تثبيت أداة… + Pin to Home تثبيت في الصفحة الرئيسية + Pin tools to Home تثبيت الأدوات في الصفحة الرئيسية + Pin your favorite tools here for quick access. ثبّت أدواتك المفضلة هنا للوصول السريع. + Pinned مثبت + Pinned Tools الأدوات المثبتة + Search tools… البحث عن الأدوات… + Select tools to display on your Home page for quick access. حدد الأدوات المراد عرضها على صفحتك الرئيسية للوصول السريع. + Unpin from Home إلغاء التثبيت من الصفحة الرئيسية + Unpin tool إلغاء تثبيت الأداة + Transform image تحويل الصورة + Select Tool أداة التحديد + Hand Tool أداة اليد + Marquee Zoom تكبير بالتحديد + Edit this PDF تحرير هذا الملف PDF + Export this PDF تصدير هذا الملف PDF + Find text or tools البحث عن نص أو أدوات Create & Edit الإنشاء والتحرير Review & Compare المراجعة والمقارنة Forms & Signatures النماذج والتوقيعات @@ -2838,6 +2861,13 @@ certificate Version الإصدار field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} a field named {name} already exists يوجد حقل باسم {name} بالفعل + Arrange image ترتيب الصورة + Bring forward إحضار للأمام + Bring to front إحضار إلى المقدمة + Crop قص + Remove crop إزالة القص + Send backward إرسال للخلف + Send to back إرسال إلى المؤخرة # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today اليوم @@ -2855,3 +2885,10 @@ certificate Version الإصدار Options for this reply خيارات هذا الرد Export value قيمة التصدير Show Page Edge Shadows إظهار ظلال حواف الصفحات + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… بحث… + Image pasted تم لصق الصورة + No image on clipboard to paste لا توجد صورة في الحافظة للصقها + Pinned {tool} to Home تم تثبيت {tool} في الرئيسية + Unpinned {tool} from Home تم إلغاء تثبيت {tool} من الرئيسية diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8fe56514df..e269febf18 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -7,6 +7,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + About За програмата Contributors Сътрудници Models Модели @@ -2432,7 +2436,7 @@ animation effects Off Изключени You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала. Turn upside down (180°) Обърни наопаки (180°) 4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm) - Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист. + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната и област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист. Find untagged content Намиране на немаркирано съдържание No untagged content was found. Не е намерено немаркирано съдържание. Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание. @@ -2478,6 +2482,26 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Clear search Изчистване на търсенето + Pin Закачане + Pin a tool… Закачане на инструмент… + Pin to Home Закачане към Начало + Pin tools to Home Закачане на инструменти към Начало + Pin your favorite tools here for quick access. Закачете любимите си инструменти тук за бърз достъп. + Pinned Закачено + Pinned Tools Закачени инструменти + Search tools… Търсене на инструменти… + Select tools to display on your Home page for quick access. Изберете инструменти за показване на началната страница за бърз достъп. + Unpin from Home Откачане от Начало + Unpin tool Откачане на инструмент + Transform image Трансформиране на изображение + Select Tool Инструмент за избор + Hand Tool Инструмент „Ръка“ + Marquee Zoom Мащабиране с рамка + Paste Постави + Edit this PDF Редактирай този PDF + Export this PDF Експортирай този PDF + Find text or tools Търсене на текст или инструменти Create & Edit Създаване и редактиране Review & Compare Преглед и сравнение Forms & Signatures Формуляри и подписи @@ -2525,6 +2549,13 @@ animation effects Off Изключени field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} two cells would both be named {name} две клетки биха имали едно и също име {name} a field named {name} already exists вече съществува поле с име {name} + Arrange image Подреждане на изображението + Bring forward Преместване напред + Bring to front Преместване най-отпред + Crop Изрязване + Remove crop Премахване на изрязването + Send backward Преместване назад + Send to back Преместване най-отзад # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Днес @@ -2542,3 +2573,10 @@ animation effects Off Изключени Options for this reply Опции за този отговор Export value Стойност за експортиране Show Page Edge Shadows Показване на сенки по краищата на страниците + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Търсене… + Image pasted Изображението е поставено + No image on clipboard to paste Няма изображение в клипборда за поставяне + Pinned {tool} to Home {tool} е закачен в Начало + Unpinned {tool} from Home {tool} е откачен от Начало diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 83750ba124..7ff0f83fee 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menú File Fitxer Edit Edita @@ -2815,6 +2819,33 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + Bring forward Porta endavant + Bring to front Porta al capdavant + Crop image Retalla la imatge + Remove crop Elimina el retall + Send backward Envia enrere + Send to back Envia al fons + Clear search Neteja la cerca + Edit this PDF Edita aquest PDF + Export this PDF Exporta aquest PDF + Find text or tools Cerca text o eines + Hand Tool Eina Mà + Marquee Zoom Zoom d'enquadrament + Pin Fixa + Pin a tool… Fixa una eina… + Pin to Home Fixa a l'Inici + Pin tools to Home Fixa eines a l'Inici + Pin your favorite tools here for quick access. Fixeu aquí les vostres eines preferides per a un accés ràpid. + Pinned Fixat + Pinned Tools Eines fixades + Search tools… Cerca eines… + Select Tool Eina Selecció + Select tools to display on your Home page for quick access. Seleccioneu les eines que es mostraran a la pàgina d'Inici per a un accés ràpid. + Transform image Transforma la imatge + Unpin from Home Desenganxa de l'Inici + Unpin tool Desenganxa l'eina + Arrange image Organitza la imatge + Crop Escapça # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui @@ -2838,3 +2869,10 @@ theme Appearance Aspecte # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcions d'aquesta resposta + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Cerca… + Image pasted Imatge enganxada + No image on clipboard to paste No hi ha cap imatge al porta-retalls per enganxar + Pinned {tool} to Home {tool} fixat a Inici + Unpinned {tool} from Home {tool} desfixat d’Inici diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index f6d91e3f42..3b6a7671ed 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -20,6 +20,10 @@ # reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. # Spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu پێڕست File فایل Edit دەستکاریکردن @@ -2735,6 +2739,13 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە + Arrange image ڕێکخستنی وێنە + Bring forward هێنانە پێشەوە + Bring to front هێنانە پێشەوەی سەرەکی + Crop بڕینەوە + Remove crop لابردنی بڕینەوە + Send backward ناردنە دواوە + Send to back ناردنە دواوەی سەرەکی Create & Edit دروستکردن و دەستکاریکردن Review & Compare پێداچوونەوە و بەراوردکردن Forms & Signatures فۆڕمەکان و واژووەکان @@ -2770,3 +2781,32 @@ certificate Version وەشان @plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. @plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. @plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… گەڕان… + Image pasted وێنەکە لکێنرا + No image on clipboard to paste هیچ وێنەیەک لە کلیپبۆرد نییە بۆ لکاندن + Pinned {tool} to Home {tool} لە ماڵەوە جێگیرکرا + Unpinned {tool} from Home {tool} لە ماڵەوە لابرا + +# Home tools (AI-assisted; not separately native-reviewed) + Clear search پاککردنەوەی گەڕان + Pin جێگیرکردن + Pin a tool… جێگیرکردنی ئامراز… + Pin to Home جێگیرکردن لە ماڵەوە + Pin tools to Home جێگیرکردنی ئامرازەکان لە ماڵەوە + Pin your favorite tools here for quick access. ئامرازە دڵخوازەکانت لێرە جێگیر بکە بۆ دەستگەیشتنی خێرا. + Pinned جێگیرکراو + Pinned Tools ئامرازە جێگیرکراوەکان + Search tools… گەڕان بەدوای ئامرازەکان… + Select tools to display on your Home page for quick access. ئامرازەکان هەڵبژێرە بۆ پیشاندان لە ماڵەوە و دەستگەیشتنی خێرا. + Unpin from Home لابردنی جێگیرکردن لە ماڵەوە + Unpin tool لابردنی جێگیرکردنی ئامراز + Transform image گۆڕینی شێوەی وێنە + Select Tool ئامرازی هەڵبژاردن + Hand Tool ئامرازی دەست + Marquee Zoom گەورەکردنی ناوچەی هەڵبژێردراو + Edit this PDF دەستکاریکردنی ئەم PDF ـە + Export this PDF هەناردەکردنی ئەم PDF ـە + Find text or tools دۆزینەوەی دەق یان ئامراز + Crop image بڕینەوەی وێنە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8ecd80d30b..22f6e3e056 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -19,6 +19,10 @@ # commands is kept. Messages with a count but no `@plural` entry use count-neutral wording # ("Vybráno stránek: {n}"), because Czech has three plural forms. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Nabídka File Soubor Rotation: Otočení: @@ -2589,6 +2593,26 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Clear search Vymazat hledání + Pin Připnout + Pin a tool… Připnout nástroj… + Pin to Home Připnout na domovskou stránku + Pin tools to Home Připnout nástroje na domovskou stránku + Pin your favorite tools here for quick access. Připněte si sem oblíbené nástroje pro rychlý přístup. + Pinned Připnuto + Pinned Tools Připnuté nástroje + Search tools… Hledat nástroje… + Select tools to display on your Home page for quick access. Vyberte nástroje, které se mají zobrazovat na domovské stránce pro rychlý přístup. + Unpin from Home Odepnout z domovské stránky + Unpin tool Odepnout nástroj + Transform image Transformovat obrázek + Select Tool Nástroj pro výběr + Hand Tool Nástroj ručička + Marquee Zoom Zvětšit vybranou oblast + Paste Vložit + Edit this PDF Upravit toto PDF + Export this PDF Exportovat toto PDF + Find text or tools Hledat text nebo nástroje Create & Edit Vytváření a úpravy Review & Compare Revize a porovnání Forms & Signatures Formuláře a podpisy @@ -2793,7 +2817,6 @@ animation effects Off Vypnuto Organize Uspořádat Page {n} Stránka {n} Page {n} of {total} Stránka {n} z {total} - Paste Vložit Previous Předchozí Previous (⇧⌘G) Předchozí (⇧⌘G) Print (⌘P) Tisk (⌘P) @@ -2869,6 +2892,14 @@ certificate Version Verze field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} two cells would both be named {name} dvě buňky by se jmenovaly {name} a field named {name} already exists pole s názvem {name} už existuje + Bring forward Přenést blíž + Bring to front Přenést do popředí + Crop image Oříznout obrázek + Remove crop Odebrat oříznutí + Send backward Přenést dál + Send to back Přenést do pozadí + Arrange image Uspořádat obrázek + Crop Oříznout # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dnes @@ -2886,3 +2917,10 @@ certificate Version Verze Options for this reply Možnosti této odpovědi Export value Exportní hodnota Show Page Edge Shadows Zobrazovat stíny okrajů stránek + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Hledat… + Image pasted Obrázek vložen + No image on clipboard to paste Ve schránce není obrázek k vložení + Pinned {tool} to Home {tool} připnuto na úvodní stránku + Unpinned {tool} from Home {tool} odepnuto z úvodní stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d6afea2c8..2c9ce7ae36 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Datei Edit Bearbeiten @@ -2516,6 +2520,26 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Clear search Suche löschen + Pin Anheften + Pin a tool… Werkzeug anheften… + Pin to Home An Start anheften + Pin tools to Home Werkzeuge an Start anheften + Pin your favorite tools here for quick access. Heften Sie Ihre bevorzugten Werkzeuge hier an, um schnell darauf zuzugreifen. + Pinned Angeheftet + Pinned Tools Angeheftete Werkzeuge + Search tools… Werkzeuge suchen… + Select tools to display on your Home page for quick access. Wählen Sie Werkzeuge für den Schnellzugriff auf Ihrer Startseite aus. + Unpin from Home Von Start lösen + Unpin tool Werkzeug lösen + Transform image Bild transformieren + Select Tool Auswahl-Werkzeug + Hand Tool Hand-Werkzeug + Marquee Zoom Auswahlrahmen-Zoom + Paste Einfügen + Edit this PDF Dieses PDF bearbeiten + Export this PDF Dieses PDF exportieren + Find text or tools Text oder Werkzeuge suchen Create & Edit Erstellen & Bearbeiten Review & Compare Prüfen & Vergleichen Forms & Signatures Formulare & Signaturen @@ -2563,6 +2587,13 @@ animation effects Off Aus field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} two cells would both be named {name} zwei Zellen hätten beide den Namen {name} a field named {name} already exists ein Feld namens {name} ist bereits vorhanden + Arrange image Bild anordnen + Bring forward Nach vorne bringen + Bring to front In den Vordergrund + Crop Zuschneiden + Remove crop Zuschnitt entfernen + Send backward Nach hinten senden + Send to back In den Hintergrund # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Heute @@ -2580,3 +2611,10 @@ animation effects Off Aus Options for this reply Optionen für diese Antwort Export value Exportwert Show Page Edge Shadows Schatten an Seitenrändern anzeigen + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Suchen… + Image pasted Bild eingefügt + No image on clipboard to paste Kein Bild in der Zwischenablage zum Einfügen + Pinned {tool} to Home {tool} an Start angeheftet + Unpinned {tool} from Home {tool} von Start gelöst diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3c2f2e2050..d83bf4aef7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2,6 +2,11 @@ # Partial, clean-room initial contribution. Entries not yet translated fall back to English. # Translate from English meanings; retain placeholders and trailing ellipses. # Greek plural forms: one (1) | other (0, 2+). + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Μενού File Αρχείο Edit Επεξεργασία @@ -2521,6 +2526,26 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Clear search Εκκαθάριση αναζήτησης + Pin Καρφίτσωμα + Pin a tool… Καρφίτσωμα εργαλείου… + Pin to Home Καρφίτσωμα στην Αρχική + Pin tools to Home Καρφίτσωμα εργαλείων στην Αρχική + Pin your favorite tools here for quick access. Καρφιτσώστε τα αγαπημένα σας εργαλεία εδώ για γρήγορη πρόσβαση. + Pinned Καρφιτσωμένο + Pinned Tools Καρφιτσωμένα εργαλεία + Search tools… Αναζήτηση εργαλείων… + Select tools to display on your Home page for quick access. Επιλέξτε εργαλεία για εμφάνιση στην Αρχική σας για γρήγορη πρόσβαση. + Unpin from Home Ξεκαρφίτσωμα από την Αρχική + Unpin tool Ξεκαρφίτσωμα εργαλείου + Transform image Μετασχηματισμός εικόνας + Select Tool Εργαλείο επιλογής + Hand Tool Εργαλείο χεριού + Marquee Zoom Μεγέθυνση επιλεγμένης περιοχής + Paste Επικόλληση + Edit this PDF Επεξεργασία αυτού του PDF + Export this PDF Εξαγωγή αυτού του PDF + Find text or tools Εύρεση κειμένου ή εργαλείων Create & Edit Δημιουργία και επεξεργασία Review & Compare Αναθεώρηση και σύγκριση Forms & Signatures Φόρμες και υπογραφές @@ -2568,6 +2593,13 @@ animation effects Off Ανενεργά field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} + Arrange image Διάταξη εικόνας + Bring forward Μεταφορά εμπρός + Bring to front Μεταφορά σε πρώτο πλάνο + Crop Περικοπή + Remove crop Κατάργηση περικοπής + Send backward Μεταφορά πίσω + Send to back Μεταφορά σε δεύτερο πλάνο # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Σήμερα @@ -2585,3 +2617,10 @@ animation effects Off Ανενεργά Options for this reply Επιλογές για αυτήν την απάντηση Export value Τιμή εξαγωγής Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Αναζήτηση… + Image pasted Η εικόνα επικολλήθηκε + No image on clipboard to paste Δεν υπάρχει εικόνα στο πρόχειρο για επικόλληση + Pinned {tool} to Home Το {tool} καρφιτσώθηκε στην Αρχική + Unpinned {tool} from Home Το {tool} ξεκαρφιτσώθηκε από την Αρχική diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f5f1b4b6ff..131d2dacaf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menú File Archivo Edit Edición @@ -2012,9 +2016,16 @@ action wizard Start Iniciar Edit text Editar texto Load document font Cargar fuente del documento Move image Mover imagen + Transform image Transformar imagen Rotate image Girar imagen Flip image Voltear imagen Delete image Eliminar imagen + Bring to front Traer al frente + Send to back Enviar al fondo + Bring forward Traer adelante + Send backward Enviar atrás + Remove crop Quitar recorte + Crop image Recortar imagen Add header & footer Añadir encabezado y pie de página Update header & footer Actualizar encabezado y pie de página Update watermark Actualizar marca de agua @@ -2679,6 +2690,25 @@ animation effects Off Desactivados # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. + Clear search Borrar búsqueda + Pin Anclar + Pin a tool… Anclar una herramienta… + Pin to Home Anclar en Inicio + Pin tools to Home Anclar herramientas en Inicio + Pin your favorite tools here for quick access. Ancla tus herramientas favoritas aquí para un acceso rápido. + Pinned Anclado + Pinned Tools Herramientas ancladas + Search tools… Buscar herramientas… + Select tools to display on your Home page for quick access. Selecciona las herramientas que se mostrarán en la página de Inicio para un acceso rápido. + Unpin from Home Desanclar de Inicio + Unpin tool Desanclar herramienta + Select Tool Herramienta Selección + Hand Tool Herramienta Mano + Marquee Zoom Zoom por selección + Paste Pegar + Edit this PDF Editar este PDF + Export this PDF Exportar este PDF + Find text or tools Buscar texto o herramientas Attach a file… Adjuntar un archivo… Delete attachment Eliminar adjunto Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web @@ -2834,7 +2864,6 @@ theme Appearance Aspecto Import Importar Move Mover Duplicate Duplicar - Paste Pegar Select all Seleccionar todo Refresh Actualizar Next Siguiente @@ -2885,6 +2914,8 @@ theme Appearance Aspecto field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} two cells would both be named {name} dos celdas se llamarían {name} a field named {name} already exists ya existe un campo llamado {name} + Arrange image Organizar imagen + Crop Recortar # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoy @@ -2902,3 +2933,10 @@ theme Appearance Aspecto Options for this reply Opciones de esta respuesta Export value Valor de exportación Show Page Edge Shadows Mostrar sombras en los bordes de las páginas + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Buscar… + Image pasted Imagen pegada + No image on clipboard to paste No hay ninguna imagen en el portapapeles para pegar + Pinned {tool} to Home {tool} fijado en Inicio + Unpinned {tool} from Home {tool} desfijado de Inicio diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 292b4fbdb3..4f9286b843 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -19,6 +19,10 @@ # each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins # compound words. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu منو File فایل Edit ویرایش @@ -2816,3 +2820,10 @@ theme Appearance ظاهر # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply گزینه‌های این پاسخ + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… جستجو… + Image pasted تصویر چسبانده شد + No image on clipboard to paste تصویری در حافظهٔ موقت برای چسباندن نیست + Pinned {tool} to Home {tool} به خانه سنجاق شد + Unpinned {tool} from Home {tool} از خانه جدا شد diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 80d2a0f859..8c1a9baab9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -16,6 +16,11 @@ # Labels use sentence case. Product and technology names (PdfCraft, PDF, OCR, JavaScript ...) # stay in Latin letters; the trailing "…" of dialog commands is kept. Calendar headings use the # nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`). + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Valikko File Tiedosto Edit Muokkaa @@ -2824,6 +2829,33 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + Bring forward Tuo eteenpäin + Bring to front Tuo etualalle + Crop image Rajaa kuva + Remove crop Poista rajaus + Send backward Vie taaksepäin + Send to back Vie taustalle + Clear search Tyhjennä haku + Edit this PDF Muokkaa tätä PDF-tiedostoa + Export this PDF Vie tämä PDF-tiedosto + Find text or tools Etsi tekstiä tai työkaluja + Hand Tool Käsityökalu + Marquee Zoom Alueen zoomaus + Pin Kiinnitä + Pin a tool… Kiinnitä työkalu… + Pin to Home Kiinnitä Aloitus-näkymään + Pin tools to Home Kiinnitä työkaluja Aloitus-näkymään + Pin your favorite tools here for quick access. Kiinnitä suosikkityökalusi tähän nopeaa käyttöä varten. + Pinned Kiinnitetty + Pinned Tools Kiinnitetyt työkalut + Search tools… Etsi työkaluja… + Select Tool Valintatyökalu + Select tools to display on your Home page for quick access. Valitse Aloitus-sivulla näytettävät työkalut nopeaa käyttöä varten. + Transform image Muunna kuvaa + Unpin from Home Irrota Aloitus-näkymästä + Unpin tool Irrota työkalu + Arrange image Järjestä kuva + Crop Rajaa # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään @@ -2847,3 +2879,10 @@ theme Appearance Teema # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tämän vastauksen asetukset + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Hae… + Image pasted Kuva liitetty + No image on clipboard to paste Leikepöydällä ei ole liitettävää kuvaa + Pinned {tool} to Home {tool} kiinnitetty aloitussivulle + Unpinned {tool} from Home {tool} irrotettu aloitussivulta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f31b3741b6..1c22cf9d81 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Fichier Edit Édition @@ -2515,6 +2519,26 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Clear search Effacer la recherche + Pin Épingler + Pin a tool… Épingler un outil… + Pin to Home Épingler à l'Accueil + Pin tools to Home Épingler des outils à l'Accueil + Pin your favorite tools here for quick access. Épinglez vos outils préférés ici pour un accès rapide. + Pinned Épinglé + Pinned Tools Outils épinglés + Search tools… Rechercher des outils… + Select tools to display on your Home page for quick access. Sélectionnez les outils à afficher sur votre page d'Accueil pour un accès rapide. + Unpin from Home Désépingler de l'Accueil + Unpin tool Désépingler l'outil + Transform image Transformer l'image + Select Tool Outil Sélection + Hand Tool Outil Main + Marquee Zoom Zoom par sélection + Paste Coller + Edit this PDF Modifier ce PDF + Export this PDF Exporter ce PDF + Find text or tools Rechercher du texte ou des outils Create & Edit Créer et modifier Review & Compare Réviser et comparer Forms & Signatures Formulaires et signatures @@ -2632,7 +2656,6 @@ animation effects Off Désactivés Page {} has additional actions La page {} contient des actions supplémentaires Panel layout could not be restored: {error} Impossible de restaurer la disposition des panneaux : {error} Panel move was not applied: {error} Le déplacement du panneau n'a pas été appliqué : {error} - Paste Coller PdfCraft can't check this signature yet: {}. PdfCraft ne peut pas encore contrôler cette signature : {}. Previous Précédent Previous (⇧⌘G) Précédent (⇧⌘G) @@ -2828,6 +2851,13 @@ certificate Version Version field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} two cells would both be named {name} deux cellules porteraient le nom {name} a field named {name} already exists un champ nommé {name} existe déjà + Arrange image Organiser l'image + Bring forward Avancer + Bring to front Mettre au premier plan + Crop Recadrer + Remove crop Supprimer le recadrage + Send backward Reculer + Send to back Mettre à l'arrière-plan # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Aujourd'hui @@ -2845,3 +2875,10 @@ certificate Version Version Options for this reply Options de cette réponse Export value Valeur d’exportation Show Page Edge Shadows Afficher les ombres des bords des pages + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Rechercher… + Image pasted Image collée + No image on clipboard to paste Aucune image à coller dans le presse-papiers + Pinned {tool} to Home {tool} épinglé à l’Accueil + Unpinned {tool} from Home {tool} désépinglé de l’Accueil diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index dfaa94b5d6..52c5450bb3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2,6 +2,11 @@ # context English source translation # Clean-room translation written from the English source's meaning. # License: MIT OR Apache-2.0. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu मेनू File फ़ाइल Edit संपादित करें @@ -2786,6 +2791,31 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + Clear search खोज साफ़ करें + Edit this PDF इस PDF को संपादित करें + Export this PDF इस PDF को निर्यात करें + Find text or tools पाठ या उपकरण ढूंढें + Hand Tool हैंड टूल + Marquee Zoom मार्की ज़ूम + Pin पिन करें + Pin a tool… उपकरण पिन करें… + Pin to Home होम पर पिन करें + Pin tools to Home होम पर उपकरण पिन करें + Pin your favorite tools here for quick access. त्वरित पहुँच के लिए अपने पसंदीदा उपकरण यहाँ पिन करें। + Pinned पिन किया गया + Pinned Tools पिन किए गए उपकरण + Search tools… उपकरण खोजें… + Select Tool चयन उपकरण + Select tools to display on your Home page for quick access. त्वरित पहुँच के लिए अपने होम पेज पर प्रदर्शित करने हेतु उपकरण चुनें। + Unpin from Home होम से अनपिन करें + Unpin tool उपकरण अनपिन करें + Arrange image छवि व्यवस्थित करें + Bring forward आगे लाएँ + Bring to front सबसे आगे लाएँ + Crop क्रॉप करें + Remove crop क्रॉप हटाएँ + Send backward पीछे भेजें + Send to back सबसे पीछे भेजें # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज @@ -2809,3 +2839,10 @@ theme Appearance स्वरूप # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply इस जवाब के विकल्प + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… खोजें… + Image pasted छवि चिपकाई गई + No image on clipboard to paste चिपकाने के लिए क्लिपबोर्ड में कोई छवि नहीं है + Pinned {tool} to Home {tool} को होम पर पिन किया गया + Unpinned {tool} from Home {tool} को होम से अनपिन किया गया diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e74944e33e..47d9c24b40 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -9,6 +9,11 @@ # consulted or copied; add entries the same way. # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Fájl Edit Szerkesztés @@ -2514,6 +2519,26 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Clear search Keresés törlése + Pin Rögzítés + Pin a tool… Eszköz rögzítése… + Pin to Home Rögzítés a Kezdőlapra + Pin tools to Home Eszközök rögzítése a Kezdőlapra + Pin your favorite tools here for quick access. Rögzítse ide kedvenc eszközeit a gyors eléréshez. + Pinned Rögzítve + Pinned Tools Rögzített eszközök + Search tools… Eszközök keresése… + Select tools to display on your Home page for quick access. Válassza ki a Kezdőlapon megjelenítendő eszközöket a gyors eléréshez. + Unpin from Home Rögzítés feloldása a Kezdőlapról + Unpin tool Eszköz rögzítésének feloldása + Transform image Kép transzformálása + Select Tool Kijelölő eszköz + Hand Tool Kéz eszköz + Marquee Zoom Kijelölő nagyítás + Paste Beillesztés + Edit this PDF Ezen PDF szerkesztése + Export this PDF Ezen PDF exportálása + Find text or tools Szöveg vagy eszközök keresése Create & Edit Létrehozás és szerkesztés Review & Compare Áttekintés és összehasonlítás Forms & Signatures Űrlapok és aláírások @@ -2561,6 +2586,13 @@ animation effects Off Ki field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} two cells would both be named {name} két cella neve is {name} lenne a field named {name} already exists már van {name} nevű mező + Arrange image Kép elrendezése + Bring forward Előrébb hozás + Bring to front Előrehozás + Crop Körülvágás + Remove crop Körülvágás eltávolítása + Send backward Hátrébb küldés + Send to back Hátraküldés # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Ma @@ -2578,3 +2610,10 @@ animation effects Off Ki Options for this reply A válasz beállításai Export value Exportérték Show Page Edge Shadows Oldalszéli árnyékok megjelenítése + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Keresés… + Image pasted Kép beillesztve + No image on clipboard to paste Nincs beilleszthető kép a vágólapon + Pinned {tool} to Home {tool} rögzítve a kezdőlapra + Unpinned {tool} from Home {tool} leválasztva a kezdőlapról diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0eb02928f1..89152f62a4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3,6 +3,11 @@ # Clean-room translations of PdfCraft's English UI labels. Use standard Indonesian computing # terms; keep product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word...) unchanged. # Indonesian does not inflect nouns for number, so @plural entries list one form. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu Edit Edit Pages Halaman @@ -2787,6 +2792,31 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + Clear search Bersihkan pencarian + Edit this PDF Edit PDF ini + Export this PDF Ekspor PDF ini + Find text or tools Temukan teks atau alat + Hand Tool Alat Tangan + Marquee Zoom Zoom Bidang + Pin Sematkan + Pin a tool… Sematkan alat… + Pin to Home Sematkan ke Beranda + Pin tools to Home Sematkan alat ke Beranda + Pin your favorite tools here for quick access. Sematkan alat favorit Anda di sini untuk akses cepat. + Pinned Disematkan + Pinned Tools Alat yang Disematkan + Search tools… Cari alat… + Select Tool Alat Pemilih + Select tools to display on your Home page for quick access. Pilih alat untuk ditampilkan di halaman Beranda Anda untuk akses cepat. + Unpin from Home Lepas sematan dari Beranda + Unpin tool Lepas sematan alat + Arrange image Atur gambar + Bring forward Maju Selangkah + Bring to front Bawa ke Depan + Crop Pangkas + Remove crop Hapus pangkasan + Send backward Mundur Selangkah + Send to back Kirim ke Belakang # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini @@ -2810,3 +2840,10 @@ theme Appearance Tampilan # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opsi untuk balasan ini + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Cari… + Image pasted Gambar ditempel + No image on clipboard to paste Tidak ada gambar di papan klip untuk ditempel + Pinned {tool} to Home {tool} disematkan ke Beranda + Unpinned {tool} from Home {tool} dilepas dari Beranda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82973756fa..0cae8f3e98 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File File Edit Modifica @@ -2727,6 +2731,26 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Clear search Cancella ricerca + Pin Fissa + Pin a tool… Fissa uno strumento… + Pin to Home Fissa sulla Home + Pin tools to Home Fissa strumenti sulla Home + Pin your favorite tools here for quick access. Fissa qui i tuoi strumenti preferiti per un accesso rapido. + Pinned Fissato + Pinned Tools Strumenti fissati + Search tools… Cerca strumenti… + Select tools to display on your Home page for quick access. Seleziona gli strumenti da visualizzare nella pagina Home per un acceso rapido. + Unpin from Home Rimuovi da Home + Unpin tool Sblocca strumento dalla Home + Transform image Trasforma immagine + Select Tool Strumento Selezione + Hand Tool Strumento Mano + Marquee Zoom Ingrandimento selettivo + Paste Incolla + Edit this PDF Modifica questo PDF + Export this PDF Esporta questo PDF + Find text or tools Trova testo o strumenti Create & Edit Crea e modifica Review & Compare Revisione e confronto Forms & Signatures Moduli e firme @@ -2794,6 +2818,13 @@ certificate SHA-256 digest Impronta SHA-256 field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} two cells would both be named {name} due celle si chiamerebbero entrambe {name} a field named {name} already exists esiste già un campo chiamato {name} + Arrange image Disponi immagine + Bring forward Porta avanti + Bring to front Porta in primo piano + Crop Ritaglia + Remove crop Rimuovi ritaglio + Send backward Porta indietro + Send to back Porta in secondo piano # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Oggi @@ -2811,3 +2842,10 @@ certificate SHA-256 digest Impronta SHA-256 Options for this reply Opzioni per questa risposta Export value Valore di esportazione Show Page Edge Shadows Mostra le ombre ai bordi delle pagine + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Cerca… + Image pasted Immagine incollata + No image on clipboard to paste Nessuna immagine negli appunti da incollare + Pinned {tool} to Home {tool} fissato alla Home + Unpinned {tool} from Home {tool} rimosso dai preferiti della Home diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ef055d436f..abc616b817 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu メニュー File ファイル Edit 編集 @@ -2515,6 +2519,26 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Clear search 検索をクリア + Pin ピン留め + Pin a tool… ツールをピン留め… + Pin to Home ホームにピン留め + Pin tools to Home ツールをホームにピン留め + Pin your favorite tools here for quick access. よく使うツールをここにピン留めして、すばやくアクセスできるようにします。 + Pinned ピン留め済み + Pinned Tools ピン留めしたツール + Search tools… ツールを検索… + Select tools to display on your Home page for quick access. すばやくアクセスできるように、ホームに表示するツールを選択します。 + Unpin from Home ホームからピン留めを解除 + Unpin tool ツールのピン留めを解除 + Transform image 画像を変換 + Select Tool 選択ツール + Hand Tool 手のひらツール + Marquee Zoom 範囲指定ズーム + Paste 貼り付け + Edit this PDF この PDF を編集 + Export this PDF この PDF を書き出す + Find text or tools テキストまたはツールを検索 Create & Edit 作成と編集 Review & Compare レビューと比較 Forms & Signatures フォームと署名 @@ -2562,6 +2586,13 @@ animation effects Off オフ field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} two cells would both be named {name} 2 つのセルが同じ名前 {name} になります a field named {name} already exists {name} という名前のフィールドは既にあります + Arrange image 画像を配置 + Bring forward 前面へ移動 + Bring to front 最前面へ移動 + Crop 切り抜き + Remove crop 切り抜きを解除 + Send backward 背面へ移動 + Send to back 最背面へ移動 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今日 @@ -2579,3 +2610,10 @@ animation effects Off オフ Options for this reply この返信のオプション Export value 書き出し値 Show Page Edge Shadows ページ端の影を表示 + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… 検索… + Image pasted 画像を貼り付けました + No image on clipboard to paste 貼り付ける画像がクリップボードにありません + Pinned {tool} to Home {tool} をホームにピン留めしました + Unpinned {tool} from Home {tool} のホームへのピン留めを解除しました diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 47061d3a25..fc6a2d783a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1,3 +1,7 @@ +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Мәзір File Файл Edit Түзету @@ -2513,6 +2517,26 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Clear search Іздеуді тазалау + Pin Бекіту + Pin a tool… Құралды бекіту… + Pin to Home Басты бетке бекіту + Pin tools to Home Құралдарды басты бетке бекіту + Pin your favorite tools here for quick access. Жылдам қол жеткізу үшін таңдаулы құралдарды осында бекітіңіз. + Pinned Бекітілген + Pinned Tools Бекітілген құралдар + Search tools… Құралдарды іздеу… + Select tools to display on your Home page for quick access. Жылдам қол жеткізу үшін басты бетте көрсетілетін құралдарды таңдаңыз. + Unpin from Home Басты беттен алып тастау + Unpin tool Құралды бекітуден шығару + Transform image Кескінді түрлендіру + Select Tool Таңдау құралы + Hand Tool Қол құралы + Marquee Zoom Аймақты масштабтау + Paste Қою + Edit this PDF Осы PDF файлын түзету + Export this PDF Осы PDF файлын экспорттау + Find text or tools Мәтінді немесе құралдарды табу Create & Edit Жасау және өңдеу Review & Compare Шолу және салыстыру Forms & Signatures Пішіндер және қолтаңбалар @@ -2560,6 +2584,13 @@ animation effects Off Өшірулі field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді a field named {name} already exists {name} атты өріс бар + Arrange image Суретті реттеу + Bring forward Алға жылжыту + Bring to front Алдыңғы планға шығару + Crop Қию + Remove crop Қиюды алып тастау + Send backward Артқа жылжыту + Send to back Артқы планға жіберу # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Бүгін @@ -2577,3 +2608,10 @@ animation effects Off Өшірулі Options for this reply Осы жауаптың параметрлері Export value Экспорт мәні Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Іздеу… + Image pasted Сурет қойылды + No image on clipboard to paste Алмасу буферінде қоятын сурет жоқ + Pinned {tool} to Home {tool} басты бетке бекітілді + Unpinned {tool} from Home {tool} басты беттен ажыратылды diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1e461bbdff..20315b5fcf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2,6 +2,11 @@ # context English source translation # Clean-room translation written from the English source's meaning. # License: MIT OR Apache-2.0. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu 메뉴 File 파일 Edit 편집 @@ -2786,6 +2791,31 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + Clear search 검색 지우기 + Edit this PDF 이 PDF 편집 + Export this PDF 이 PDF 내보내기 + Find text or tools 텍스트 또는 도구 찾기 + Hand Tool 손 도구 + Marquee Zoom 영역 확대/축소 + Pin 고정 + Pin a tool… 도구 고정… + Pin to Home 홈에 고정 + Pin tools to Home 홈에 도구 고정 + Pin your favorite tools here for quick access. 빠른 액세스를 위해 즐겨찾는 도구를 여기에 고정하세요. + Pinned 고정됨 + Pinned Tools 고정된 도구 + Search tools… 도구 검색… + Select Tool 선택 도구 + Select tools to display on your Home page for quick access. 빠른 액세스를 위해 홈 페이지에 표시할 도구를 선택하세요. + Unpin from Home 홈에서 고정 해제 + Unpin tool 도구 고정 해제 + Arrange image 이미지 정렬 + Bring forward 앞으로 가져오기 + Bring to front 맨 앞으로 가져오기 + Crop 자르기 + Remove crop 자르기 제거 + Send backward 뒤로 보내기 + Send to back 맨 뒤로 보내기 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 @@ -2809,3 +2839,10 @@ theme Appearance 모양 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 이 답글의 옵션 + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… 검색… + Image pasted 이미지를 붙여넣었습니다 + No image on clipboard to paste 클립보드에 붙여넣을 이미지가 없습니다 + Pinned {tool} to Home {tool}을 홈에 고정했습니다 + Unpinned {tool} from Home {tool}의 홈 고정을 해제했습니다 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index c4d45c90d1..ea684f6d59 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "..." of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu ເມນູ File ໄຟລ໌ Edit ແກ້ໄຂ @@ -2451,6 +2455,13 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ + Arrange image ຈັດຮູບພາບ + Bring forward ນຳມາທາງໜ້າ + Bring to front ນຳມາໄວ້ໜ້າສຸດ + Crop ຕັດຂອບ + Remove crop ຍົກເລີກການຕັດຂອບ + Send backward ສົ່ງໄປທາງຫຼັງ + Send to back ສົ່ງໄປໄວ້ຫຼັງສຸດ Create & Edit ສ້າງ & ແກ້ໄຂ Review & Compare ກວດກາຄືນ & ປຽບທຽບ Forms & Signatures ຟອມ & ລາຍເຊັນ @@ -2481,3 +2492,33 @@ animation effects Off ປິດ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… ຄົ້ນຫາ… + Image pasted ວາງຮູບແລ້ວ + No image on clipboard to paste ບໍ່ມີຮູບໃນຄລິບບອດເພື່ອວາງ + Pinned {tool} to Home ປັກໝຸດ {tool} ໄວ້ໜ້າຫຼັກແລ້ວ + Unpinned {tool} from Home ຖອນໝຸດ {tool} ຈາກໜ້າຫຼັກແລ້ວ + +# Home tools (AI-assisted; not separately native-reviewed) + Clear search ລ້າງການຄົ້ນຫາ + Pin ປັກໝຸດ + Pin a tool… ປັກໝຸດເຄື່ອງມື… + Pin to Home ປັກໝຸດໄວ້ໜ້າຫຼັກ + Pin tools to Home ປັກໝຸດເຄື່ອງມືໄວ້ໜ້າຫຼັກ + Pin your favorite tools here for quick access. ປັກໝຸດເຄື່ອງມືທີ່ມັກໄວ້ທີ່ນີ້ເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ. + Pinned ປັກໝຸດແລ້ວ + Pinned Tools ເຄື່ອງມືທີ່ປັກໝຸດ + Search tools… ຄົ້ນຫາເຄື່ອງມື… + Select tools to display on your Home page for quick access. ເລືອກເຄື່ອງມືໃຫ້ສະແດງໃນໜ້າຫຼັກເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ. + Unpin from Home ຖອນໝຸດຈາກໜ້າຫຼັກ + Unpin tool ຖອນໝຸດເຄື່ອງມື + Transform image ປ່ຽນຮູບຮ່າງຮູບ + Select Tool ເຄື່ອງມືເລືອກ + Hand Tool ເຄື່ອງມືມື + Marquee Zoom ຊູມພື້ນທີ່ເລືອກ + Paste ວາງ + Edit this PDF ແກ້ໄຂ PDF ນີ້ + Export this PDF ສົ່ງອອກ PDF ນີ້ + Find text or tools ຊອກຫາຂໍ້ຄວາມ ຫຼື ເຄື່ອງມື + Crop image ຕັດຂອບຮູບພາບ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 9385c27907..fd7f431154 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -4,6 +4,10 @@ # 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. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meniu Edit Redaguoti Pages Puslapiai @@ -2765,6 +2769,12 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + Bring forward Perkelti į priekį + Bring to front Iškelti į priekį + Crop image Apkarpyti paveikslėlį + Remove crop Pašalinti apkarpymą + Send backward Perkelti atgal + Send to back Nukelti į galą Create & Edit Kūrimas ir redagavimas Review & Compare Peržiūra ir palyginimas Forms & Signatures Formos ir parašai @@ -2773,7 +2783,6 @@ key Space Tarpas No tools match your search. Paieškos neatitinka joks įrankis. Expand all tools Išskleisti visus įrankius Collapse all tools Suskleisti visus įrankius - # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. Mouse wheel scroll speed Pelės ratuko slinkimo greitis @@ -2781,6 +2790,25 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Clear search Išvalyti paiešką + Pin Prisegti + Pin a tool… Prisegti įrankį… + Pin to Home Prisegti pradžioje + Pin tools to Home Prisegti įrankius pradžioje + Pin your favorite tools here for quick access. Prisekite mėgstamus įrankius čia, kad greitai pasiektumėte. + Pinned Prisegta + Pinned Tools Prisegti įrankiai + Search tools… Ieškoti įrankių… + Select tools to display on your Home page for quick access. Pasirinkite įrankius, kuriuos norite rodyti pradžios puslapyje. + Unpin from Home Atsegti nuo pradžios + Unpin tool Atsegti įrankį + Transform image Transformuoti paveikslėlį + Select Tool Pasirinkimo įrankis + Hand Tool Rankos įrankis + Marquee Zoom Didinti pažymėtą sritį + Edit this PDF Redaguoti šį PDF + Export this PDF Eksportuoti šį PDF + Find text or tools Rasti tekstą arba įrankius Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas @@ -2823,6 +2851,8 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 + Arrange image Tvarkyti vaizdą + Crop Apkarpyti # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Šiandien @@ -2840,3 +2870,10 @@ theme Appearance Išvaizda Options for this reply Šio atsakymo parinktys Export value Eksporto reikšmė Show Page Edge Shadows Rodyti puslapių kraštų šešėlius + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Ieškoti… + Image pasted Paveikslėlis įklijuotas + No image on clipboard to paste Iškarpinėje nėra paveikslėlio įklijuoti + Pinned {tool} to Home {tool} prisegtas pradžios puslapyje + Unpinned {tool} from Home {tool} atsegtas nuo pradžios puslapio diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index e81b84e341..b54b3e3526 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -7,6 +7,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Allow Tillat Cancel Avbryt Only allow this if you trust the source document. Tillat dette bare hvis du stoler på kildedokumentet. @@ -2724,6 +2728,13 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + Arrange image Ranger bilde + Bring forward Flytt fremover + Bring to front Plasser lengst frem + Crop Beskjær + Remove crop Fjern beskjæring + Send backward Flytt bakover + Send to back Plasser lengst bak # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today I dag @@ -2754,3 +2765,32 @@ key Space Mellomrom @plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. @plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. @plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Søk… + Image pasted Bilde limt inn + No image on clipboard to paste Ingen bilder på utklippstavlen å lime inn + Pinned {tool} to Home Festet {tool} til Hjem + Unpinned {tool} from Home Løsnet {tool} fra Hjem + +# Home tools (AI-assisted; not separately native-reviewed) + Clear search Tøm søk + Pin Fest + Pin a tool… Fest et verktøy… + Pin to Home Fest til Hjem + Pin tools to Home Fest verktøy til Hjem + Pin your favorite tools here for quick access. Fest favorittverktøyene dine her for rask tilgang. + Pinned Festet + Pinned Tools Festede verktøy + Search tools… Søk etter verktøy… + Select tools to display on your Home page for quick access. Velg verktøy som skal vises på hjemmesiden for rask tilgang. + Unpin from Home Løsne fra Hjem + Unpin tool Løsne verktøy + Transform image Transformer bilde + Select Tool Markeringsverktøy + Hand Tool Håndverktøy + Marquee Zoom Områdezoom + Edit this PDF Rediger denne PDF-en + Export this PDF Eksporter denne PDF-en + Find text or tools Finn tekst eller verktøy + Crop image Beskjær bilde diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e333b1075e..208dd1c61e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Plik Edit Edycja @@ -2491,6 +2495,26 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Clear search Wyczyść wyszukiwanie + Pin Przypnij + Pin a tool… Przypnij narzędzie… + Pin to Home Przypnij do strony głównej + Pin tools to Home Przypnij narzędzia do strony głównej + Pin your favorite tools here for quick access. Przypnij tutaj ulubione narzędzia, aby mieć do nich szybki dostęp. + Pinned Przypięte + Pinned Tools Przypięte narzędzia + Search tools… Szukaj narzędzi… + Select tools to display on your Home page for quick access. Wybierz narzędzia do wyświetlenia na stronie głównej, aby mieć do nich szybki dostęp. + Unpin from Home Odepnij od strony głównej + Unpin tool Odepnij narzędzie + Transform image Przekształć obraz + Select Tool Narzędzie Zaznaczanie + Hand Tool Narzędzie Rączka + Marquee Zoom Powiększ zaznaczony obszar + Paste Wklej + Edit this PDF Edytuj ten plik PDF + Export this PDF Eksportuj ten plik PDF + Find text or tools Znajdź tekst lub narzędzia Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy @@ -2658,6 +2682,13 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje + Arrange image Rozmieść obraz + Bring forward Przesuń do przodu + Bring to front Przesuń na wierzch + Crop Przytnij + Remove crop Usuń przycięcie + Send backward Przesuń do tyłu + Send to back Przesuń pod spód # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dzisiaj @@ -2675,3 +2706,10 @@ animation effects Off Wyłączone Options for this reply Opcje tej odpowiedzi Export value Wartość eksportowa Show Page Edge Shadows Pokaż cienie krawędzi stron + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Szukaj… + Image pasted Wklejono obraz + No image on clipboard to paste Brak obrazu do wklejenia w schowku + Pinned {tool} to Home Przypięto {tool} do strony głównej + Unpinned {tool} from Home Odpięto {tool} od strony głównej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cd6d8f3e2f..a172a03879 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Arquivo Rotation: Rotação: @@ -2529,6 +2533,25 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Clear search Limpar pesquisa + Pin Fixar + Pin a tool… Fixar uma ferramenta… + Pin to Home Fixar no Início + Pin tools to Home Fixar ferramentas no Início + Pin your favorite tools here for quick access. Fixe suas ferramentas favoritas aqui para acesso rápido. + Pinned Fixado + Pinned Tools Ferramentas fixadas + Search tools… Pesquisar ferramentas… + Select tools to display on your Home page for quick access. Selecione ferramentas para exibir na página Início para acesso rápido. + Unpin from Home Desafixar do Início + Unpin tool Desafixar ferramenta + Transform image Transformar imagem + Select Tool Ferramenta Seleção + Hand Tool Ferramenta Mão + Marquee Zoom Zoom por seleção + Edit this PDF Editar este PDF + Export this PDF Exportar este PDF + Find text or tools Localizar texto ou ferramentas Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas @@ -2847,6 +2870,14 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 + Bring forward Avançar + Bring to front Trazer para a frente + Crop image Cortar imagem + Remove crop Remover corte + Send backward Recuar + Send to back Enviar para trás + Arrange image Organizar imagem + Crop Cortar # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoje @@ -2864,3 +2895,10 @@ theme Appearance Aparência Options for this reply Opções desta resposta Export value Valor de exportação Show Page Edge Shadows Mostrar sombras nas bordas das páginas + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Pesquisar… + Image pasted Imagem colada + No image on clipboard to paste Nenhuma imagem na área de transferência para colar + Pinned {tool} to Home {tool} fixado no Início + Unpinned {tool} from Home {tool} desafixado do Início diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 6b23515b8e..0d0fbf16b4 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -9,6 +9,10 @@ # the trailing "…" of dialog commands is kept. Commands use the noun form ("Salvare", "Deschidere…"), # messages address the user formally ("Alegeți", "dvs."). +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meniu Edit Editare Pages Pagini @@ -2815,6 +2819,33 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + Bring forward Aduce mai în față + Bring to front Aduce în prim-plan + Crop image Decupare imagine + Remove crop Elimină decuparea + Send backward Trimite mai în spate + Send to back Trimite în fundal + Clear search Golește căutarea + Edit this PDF Editează acest PDF + Export this PDF Exportă acest PDF + Find text or tools Găsește text sau instrumente + Hand Tool Instrumentul Mână + Marquee Zoom Zoom cadru + Pin Fixează + Pin a tool… Fixează un instrument… + Pin to Home Fixează la Pagina de pornire + Pin tools to Home Fixează instrumente la Pagina de pornire + Pin your favorite tools here for quick access. Fixează instrumentele preferate aici pentru acces rapid. + Pinned Fixat + Pinned Tools Instrumente fixate + Search tools… Caută instrumente… + Select Tool Instrumentul Selecție + Select tools to display on your Home page for quick access. Selectează instrumentele de afișat pe Pagina de pornire pentru acces rapid. + Transform image Transformare imagine + Unpin from Home Anulează fixarea la Pagina de pornire + Unpin tool Anulează fixarea instrumentului + Arrange image Aranjează imaginea + Crop Decupare # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi @@ -2838,3 +2869,10 @@ theme Appearance Aspect # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opțiuni pentru acest răspuns + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Caută… + Image pasted Imagine lipită + No image on clipboard to paste Nicio imagine în clipboard de lipit + Pinned {tool} to Home {tool} fixat pe pagina principală + Unpinned {tool} from Home {tool} desprins de pe pagina principală diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 047c01228e..b45d73ecab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Меню File Файл Edit Редактирование @@ -2428,6 +2432,26 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Clear search Очистить поиск + Pin Закрепить + Pin a tool… Закрепить инструмент… + Pin to Home Закрепить на Главной + Pin tools to Home Закрепить инструменты на Главной + Pin your favorite tools here for quick access. Закрепите здесь избранные инструменты для быстрого доступа. + Pinned Закреплено + Pinned Tools Закрепленные инструменты + Search tools… Поиск инструментов… + Select tools to display on your Home page for quick access. Выберите инструменты для отображения на Главной странице для быстрого доступа. + Unpin from Home Открепить от Главной + Unpin tool Открепить инструмент + Transform image Трансформировать изображение + Select Tool Инструмент «Выделение» + Hand Tool Инструмент «Рука» + Marquee Zoom Масштаб выделением + Paste Вставить + Edit this PDF Редактировать этот PDF + Export this PDF Экспортировать этот PDF + Find text or tools Найти текст или инструменты Create & Edit Создание и редактирование Review & Compare Рецензирование и сравнение Forms & Signatures Формы и подписи @@ -2475,6 +2499,13 @@ animation effects Off Выкл field names can't contain a period: {name} имена полей не могут содержать точку: {name} two cells would both be named {name} две ячейки получили бы одно имя {name} a field named {name} already exists поле с именем {name} уже существует + Arrange image Упорядочить изображение + Bring forward Переместить вперед + Bring to front На передний план + Crop Обрезать + Remove crop Убрать обрезку + Send backward Переместить назад + Send to back На задний план # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сегодня @@ -2492,3 +2523,10 @@ animation effects Off Выкл Options for this reply Параметры этого ответа Export value Значение экспорта Show Page Edge Shadows Показывать тени по краям страниц + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Поиск… + Image pasted Изображение вставлено + No image on clipboard to paste В буфере обмена нет изображения для вставки + Pinned {tool} to Home {tool} закреплён на главной + Unpinned {tool} from Home {tool} откреплён от главной diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 63c3a3f77d..c95f6d6bd6 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meni File Datoteka Edit Urejanje @@ -2520,6 +2524,26 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Clear search Počisti iskanje + Pin Pripni + Pin a tool… Pripni orodje… + Pin to Home Pripni na domov + Pin tools to Home Pripni orodja na domov + Pin your favorite tools here for quick access. Tukaj pripnite svoja priljubljena orodja za hiter dostop. + Pinned Pripeto + Pinned Tools Pripeta orodja + Search tools… Išči orodja… + Select tools to display on your Home page for quick access. Izberite orodja za prikaz na domači strani za hiter dostop. + Unpin from Home Odpni z domače strani + Unpin tool Odpni orodje + Transform image Preoblikuj sliko + Select Tool Orodje za izbiro + Hand Tool Orodje z roko + Marquee Zoom Povečava z okvirjem + Paste Prilepi + Edit this PDF Uredi ta PDF + Export this PDF Izvozi ta PDF + Find text or tools Poišči besedilo ali orodja Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi @@ -2567,6 +2591,13 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja + Arrange image Uredi sliko + Bring forward Premakni naprej + Bring to front Postavi v ospredje + Crop Obreži + Remove crop Odstrani obrezovanje + Send backward Premakni nazaj + Send to back Postavi v ozadje # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Danes @@ -2584,3 +2615,10 @@ animation effects Off Izklopljeni Options for this reply Možnosti tega odgovora Export value Izvozna vrednost Show Page Edge Shadows Prikaži sence robov strani + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Išči… + Image pasted Slika prilepljena + No image on clipboard to paste V odložišču ni slike za lepljenje + Pinned {tool} to Home {tool} pripeto na domačo stran + Unpinned {tool} from Home {tool} odpeto z domače strani diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index e5a8f5b037..eb9eed3a0e 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menyja File Skedari Edit Redakto @@ -2385,7 +2389,7 @@ animation effects Off Joaktiv Collapse all pages Collapse all pages Counting words… {n} pages read Counting words… {n} pages read Create Link… Create Link… - Crop Image Crop Image + Crop Image Prit imazhin Current page only Current page only Cut Image Cut Image Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. @@ -2463,7 +2467,7 @@ animation effects Off Joaktiv Recognize Text… Recognize Text… Redact Image Redact Image Redaction mark appearance Redaction mark appearance - Remove Crop Remove Crop + Remove Crop Hiq prerjen Remove this leaf tag from reading order and mark its direct content as an artifact Remove this leaf tag from reading order and mark its direct content as an artifact Resolution: Resolution: Save anyway Save anyway @@ -2541,6 +2545,32 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + Clear search Pastro kërkimin + Edit this PDF Redakto këtë PDF + Export this PDF Eksporto këtë PDF + Find text or tools Gjej tekst ose mjete + Hand Tool Mjeti Dorë + Marquee Zoom Zmadhim me kornizë + Paste Ngjit + Pin Fikso + Pin a tool… Fikso një mjet… + Pin to Home Fikso në Ballinë + Pin tools to Home Fikso mjetet në Ballinë + Pin your favorite tools here for quick access. Fiksoni mjetet tuaja të preferuara këtu për qasje të shpejtë. + Pinned I fiksuar + Pinned Tools Mjete të fiksuara + Search tools… Kërko mjete… + Select Tool Mjeti Përzgjedhje + Select tools to display on your Home page for quick access. Zgjidhni mjetet për t'u shfaqur në faqen tuaj të Ballinës për qasje të shpejtë. + Unpin from Home Hiq fiksimin nga Ballina + Unpin tool Hiq fiksimin e mjetit + Arrange image Rregullo figurën + Bring forward Kalo përpara + Bring to front Sille përpara + Crop Prit + Remove crop Hiq prerjen + Send backward Kalo prapa + Send to back Dërgo mbrapa # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot @@ -2564,3 +2594,10 @@ animation effects Off Joaktiv # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opsionet për këtë përgjigje + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Kërko… + Image pasted Imazhi u ngjit + No image on clipboard to paste Nuk ka imazh në kujtesën e përkohshme për të ngjitur + Pinned {tool} to Home {tool} u fiksua te Kreu + Unpinned {tool} from Home {tool} u hoq nga fiksimi te Kreu diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 6186eb1713..9b81ffb8b9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -12,6 +12,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu మెనూ File ఫైల్ Edit సవరణ @@ -2424,6 +2428,26 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Clear search శోధనను తీసివేయి + Pin పిన్ చేయి + Pin a tool… ఒక సాధనాన్ని పిన్ చేయి… + Pin to Home హోమ్‌కు పిన్ చేయి + Pin tools to Home సాధనాలను హోమ్‌కు పిన్ చేయి + Pin your favorite tools here for quick access. త్వరిత ప్రాప్యత కోసం మీకు ఇష్టమైన సాధనాలను ఇక్కడ పిన్ చేయండి. + Pinned పిన్ చేయబడింది + Pinned Tools పిన్ చేసిన సాధనాలు + Search tools… సాధనాలను శోధించండి… + Select tools to display on your Home page for quick access. త్వరిత ప్రాప్యత కోసం మీ హోమ్ పేజీలో ప్రదర్శించడానికి సాధనాలను ఎంచుకోండి. + Unpin from Home హోమ్ నుండి అన్‌పిన్ చేయి + Unpin tool సాధనాన్ని అన్‌పిన్ చేయి + Transform image చిత్రాన్ని రూపాంతరం చేయండి + Select Tool ఎంపిక సాధనం + Hand Tool హ్యాండ్ సాధనం + Marquee Zoom ఎంచుకున్న ప్రాంతానికి జూమ్ + Paste అతికించు + Edit this PDF ఈ PDFను సవరించు + Export this PDF ఈ PDFను ఎగుమти చేయి + Find text or tools వచనం లేదా సాధనాలను కనుగొనండి Create & Edit సృష్టించు & సవరించు Review & Compare సమీక్షించు & పోల్చు Forms & Signatures ఫారమ్‌లు & సంతకాలు @@ -2471,6 +2495,13 @@ animation effects Off ఆఫ్ field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది + Arrange image చిత్రాన్ని అమర్చు + Bring forward ముందుకు తీసుకురా + Bring to front అందరికంటే ముందుకు తీసుకురా + Crop కత్తిరించు + Remove crop కత్తిరింపును తీసివేయి + Send backward వెనుకకు పంపు + Send to back అందరికంటే వెనుకకు పంపు # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు @@ -2488,3 +2519,10 @@ animation effects Off ఆఫ్ Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు Export value ఎగుమతి విలువ Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… శోధించండి… + Image pasted చిత్రం అతికించబడింది + No image on clipboard to paste అతికించడానికి క్లిప్‌బోర్డ్‌లో చిత్రం లేదు + Pinned {tool} to Home {tool} హోమ్‌కు పిన్ చేయబడింది + Unpinned {tool} from Home {tool} హోమ్ నుండి అన్‌పిన్ చేయబడింది diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 41a4578cf0..22110de938 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1217,3 +1217,12 @@ fn date_languages_are_the_interface_languages() { let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect(); assert_eq!(dates, interface); } + +#[test] +fn image_paste_and_home_notifications_are_translated() { + for language in Lang::all().filter(|l| *l != Lang::EN) { + for source in ["Search…", "Image pasted", "No image on clipboard to paste", "Pinned {tool} to Home", "Unpinned {tool} from Home"] { + assert!(language.catalog().plain(source).is_some(), "{}: {source}", language.code()); + } + } +} diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 0c2b58a812..f7f8a65c5c 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -7,6 +7,10 @@ # names stay in Latin letters; the trailing ellipsis of dialog commands is kept. Thai has one # plural form. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu เมนู File ไฟล์ Edit แก้ไข @@ -2711,6 +2715,13 @@ key Space Space Collapse all tools ยุบเครื่องมือทั้งหมด The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + Arrange image จัดเรียงรูปภาพ + Bring forward นำไปข้างหน้า + Bring to front นำไปไว้ข้างหน้าสุด + Crop ครอบตัด + Remove crop เอาการครอบตัดออก + Send backward ส่งไปข้างหลัง + Send to back ส่งไปไว้ข้างหลังสุด # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today วันนี้ @@ -2736,3 +2747,32 @@ theme Appearance ลักษณะที่ปรากฏ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… ค้นหา… + Image pasted วางรูปภาพแล้ว + No image on clipboard to paste ไม่มีรูปภาพในคลิปบอร์ดให้วาง + Pinned {tool} to Home ปักหมุด {tool} ไว้ที่หน้าแรกแล้ว + Unpinned {tool} from Home เลิกปักหมุด {tool} จากหน้าแรกแล้ว + +# Home tools (AI-assisted; not separately native-reviewed) + Clear search ล้างการค้นหา + Pin ปักหมุด + Pin a tool… ปักหมุดเครื่องมือ… + Pin to Home ปักหมุดไว้ที่หน้าแรก + Pin tools to Home ปักหมุดเครื่องมือไว้ที่หน้าแรก + Pin your favorite tools here for quick access. ปักหมุดเครื่องมือโปรดที่นี่เพื่อเข้าถึงอย่างรวดเร็ว + Pinned ปักหมุดแล้ว + Pinned Tools เครื่องมือที่ปักหมุด + Search tools… ค้นหาเครื่องมือ… + Select tools to display on your Home page for quick access. เลือกเครื่องมือที่จะแสดงบนหน้าแรกเพื่อเข้าถึงอย่างรวดเร็ว + Unpin from Home เลิกปักหมุดจากหน้าแรก + Unpin tool เลิกปักหมุดเครื่องมือ + Transform image แปลงรูปภาพ + Select Tool เครื่องมือเลือก + Hand Tool เครื่องมือมือ + Marquee Zoom ซูมพื้นที่ที่เลือก + Edit this PDF แก้ไข PDF นี้ + Export this PDF ส่งออก PDF นี้ + Find text or tools ค้นหาข้อความหรือเครื่องมือ + Crop image ครอบตัดรูปภาพ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8a993af80..6921a6c33e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4,6 +4,10 @@ # # Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Dosya Edit Düzen @@ -2511,6 +2515,26 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Clear search Aramayı temizle + Pin Sabitle + Pin a tool… Bir aracı sabitle… + Pin to Home Giriş'e sabitle + Pin tools to Home Araçları Giriş'e sabitle + Pin your favorite tools here for quick access. Hızlı erişim için favori araçlarınızı buraya sabitleyin. + Pinned Sabitlendi + Pinned Tools Sabitlenen Araçlar + Search tools… Araçları ara… + Select tools to display on your Home page for quick access. Hızlı erişim için Giriş sayfanızda görüntülenecek araçları seçin. + Unpin from Home Giriş'ten sabitlemeyi kaldır + Unpin tool Aracın sabitlemesini kaldır + Transform image Görüntüyü dönüştür + Select Tool Seçim Aracı + Hand Tool El Aracı + Marquee Zoom Seçim yakınlaştırma + Paste Yapıştır + Edit this PDF Bu PDF'i düzenle + Export this PDF Bu PDF'i dışa aktar + Find text or tools Metin veya araç bul Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar @@ -2558,6 +2582,13 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var + Arrange image Resmi düzenle + Bring forward Öne getir + Bring to front En öne getir + Crop Kırp + Remove crop Kırpmayı kaldır + Send backward Arkaya gönder + Send to back En arkaya gönder # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Bugün @@ -2575,3 +2606,10 @@ animation effects Off Kapalı Options for this reply Bu yanıtın seçenekleri Export value Dışa aktarma değeri Show Page Edge Shadows Sayfa kenarı gölgelerini göster + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Ara… + Image pasted Görüntü yapıştırıldı + No image on clipboard to paste Panoda yapıştırılacak görüntü yok + Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi + Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 566e673176..a824769600 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -18,6 +18,10 @@ # Integer plurals (CLDR uk): one (1, 21) | few (2–4, 22–24) | many (0, 5–19); 11–14 take many. # Commands use infinitives. Fixed-count templates use count-neutral wording. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Меню File Файл Edit Редагування @@ -2537,6 +2541,26 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Clear search Очистити пошук + Pin Закріпити + Pin a tool… Закріпити інструмент… + Pin to Home Закріпити на Головній + Pin tools to Home Закріпити інструменти на Головній + Pin your favorite tools here for quick access. Закріпіть улюблені інструменти тут для швидкого доступу. + Pinned Закріплено + Pinned Tools Закріплені інструменти + Search tools… Шукати інструменти… + Select tools to display on your Home page for quick access. Виберіть інструменти для відображення на Головній для швидкого доступу. + Unpin from Home Відкріпити від Головної + Unpin tool Відкріпити інструмент + Transform image Трансформувати зображення + Select Tool Інструмент виділення + Hand Tool Інструмент «Рука» + Marquee Zoom Масштабувати виділену область + Paste Вставити + Edit this PDF Редактировать цей PDF + Export this PDF Експортувати цей PDF + Find text or tools Знайти текст або інструменти Create & Edit Створення й редагування Review & Compare Рецензування й порівняння Forms & Signatures Форми й підписи @@ -2584,6 +2608,13 @@ animation effects Off Вимкнено field names can't contain a period: {name} імена полів не можуть містити крапку: {name} two cells would both be named {name} дві клітинки мали б однакове ім'я {name} a field named {name} already exists поле з іменем {name} уже існує + Arrange image Упорядкувати зображення + Bring forward Перемістити вперед + Bring to front На передній план + Crop Обрізати + Remove crop Прибрати обрізання + Send backward Перемістити назад + Send to back На задній план # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сьогодні @@ -2601,3 +2632,10 @@ animation effects Off Вимкнено Options for this reply Параметри цієї відповіді Export value Значення експорту Show Page Edge Shadows Показувати тіні на краях сторінок + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Пошук… + Image pasted Зображення вставлено + No image on clipboard to paste У буфері обміну немає зображення для вставлення + Pinned {tool} to Home {tool} закріплено на головній + Unpinned {tool} from Home {tool} відкріплено від головної diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 41063cc0da..e79a8aacc4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7,6 +7,11 @@ # Plural rule: `plural_none` — every @plural row lists exactly one form. # Context-sensitive wording and intentional unchanged strings are regression-tested in i18n/mod.rs. # + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Trình đơn Edit Chỉnh sửa Pages Trang @@ -2702,6 +2707,12 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + Bring forward Chuyển lên trước + Bring to front Đưa lên trước cùng + Crop image Cắt hình ảnh + Remove crop Xóa cắt + Send backward Chuyển về sau + Send to back Đưa về sau cùng Create & Edit Tạo và chỉnh sửa Review & Compare Xem xét và so sánh Forms & Signatures Biểu mẫu và chữ ký @@ -2789,6 +2800,25 @@ key Space Space # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. + Clear search Xóa tìm kiếm + Pin Ghim + Pin a tool… Ghim công cụ… + Pin to Home Ghim vào Trang chủ + Pin tools to Home Ghim công cụ vào Trang chủ + Pin your favorite tools here for quick access. Ghim các công cụ yêu thích của bạn vào đây để truy cập nhanh. + Pinned Đã ghim + Pinned Tools Công cụ đã ghim + Search tools… Tìm kiếm công cụ… + Select tools to display on your Home page for quick access. Chọn các công cụ hiển thị trên Trang chủ để truy cập nhanh. + Unpin from Home Bỏ ghim khỏi Trang chủ + Unpin tool Bỏ ghim công cụ + Transform image Chuyển đổi hình ảnh + Select Tool Công cụ chọn + Hand Tool Công cụ bàn tay + Marquee Zoom Thu phóng theo vùng chọn + Edit this PDF Chỉnh sửa PDF này + Export this PDF Xuất PDF này + Find text or tools Tìm văn bản hoặc công cụ Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web @@ -2826,6 +2856,8 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + Arrange image Sắp xếp hình ảnh + Crop Xén # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hôm nay @@ -2846,3 +2878,10 @@ key Space Space Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất Show Page Edge Shadows Hiển thị bóng viền trang + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Tìm kiếm… + Image pasted Đã dán hình ảnh + No image on clipboard to paste Không có hình ảnh trong bảng nhớ tạm để dán + Pinned {tool} to Home Đã ghim {tool} vào Trang chủ + Unpinned {tool} from Home Đã bỏ ghim {tool} khỏi Trang chủ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6089c942fd..96e9c3bb75 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + # About, contributor/model credits, and multiline interface guidance. About 关于 Contributors 贡献者 @@ -2479,6 +2483,26 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Clear search 清除搜索 + Pin 固定 + Pin a tool… 固定工具… + Pin to Home 固定到主页 + Pin tools to Home 将工具固定到主页 + Pin your favorite tools here for quick access. 将常用的工具固定在此处以便快速访问。 + Pinned 已固定 + Pinned Tools 已固定的工具 + Search tools… 搜索工具… + Select tools to display on your Home page for quick access. 选择要在主页上显示的工具以便快速访问。 + Unpin from Home 从主页取消固定 + Unpin tool 取消固定工具 + Transform image 变换图像 + Select Tool 选择工具 + Hand Tool 手形工具 + Marquee Zoom 选框缩放 + Paste 粘贴 + Edit this PDF 编辑此 PDF + Export this PDF 导出此 PDF + Find text or tools 查找文本或工具 Create & Edit 创建和编辑 Review & Compare 审阅和比较 Forms & Signatures 表单和签名 @@ -2526,6 +2550,13 @@ animation effects Off 关 field names can't contain a period: {name} 字段名称不能包含句点:{name} two cells would both be named {name} 两个单元格的名称都会是 {name} a field named {name} already exists 已存在名为 {name} 的字段 + Arrange image 排列图像 + Bring forward 上移一层 + Bring to front 置于顶层 + Crop 裁剪 + Remove crop 移除裁剪 + Send backward 下移一层 + Send to back 置于底层 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @@ -2543,3 +2574,10 @@ animation effects Off 关 Options for this reply 此回复的选项 Export value 导出值 Show Page Edge Shadows 显示页面边缘阴影 + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… 搜索… + Image pasted 已粘贴图像 + No image on clipboard to paste 剪贴板中没有可粘贴的图像 + Pinned {tool} to Home 已将 {tool} 固定到主页 + Unpinned {tool} from Home 已从主页取消固定 {tool} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 292e399579..a1b933b830 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu 選單 File 檔案 Edit 編輯 @@ -2465,6 +2469,26 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Clear search 清除搜尋 + Pin 釘選 + Pin a tool… 釘選工具… + Pin to Home 釘選到首頁 + Pin tools to Home 將工具釘選到首頁 + Pin your favorite tools here for quick access. 將常用的工具釘選在此處以便快速存取。 + Pinned 已釘選 + Pinned Tools 已釘選的工具 + Search tools… 搜尋工具… + Select tools to display on your Home page for quick access. 選取要在首頁上顯示的工具以便快速存取。 + Unpin from Home 從首頁取消釘選 + Unpin tool 取消釘選工具 + Transform image 變換影像 + Select Tool 選取工具 + Hand Tool 手形工具 + Marquee Zoom 選取範圍縮放 + Paste 貼上 + Edit this PDF 編輯此 PDF + Export this PDF 匯出此 PDF + Find text or tools 尋找文字或工具 Create & Edit 建立與編輯 Review & Compare 審閱與比較 Forms & Signatures 表單與簽名 @@ -2512,6 +2536,13 @@ animation effects Off 關 field names can't contain a period: {name} 欄位名稱不能包含句點:{name} two cells would both be named {name} 兩個儲存格的名稱都會是 {name} a field named {name} already exists 已有名為 {name} 的欄位 + Arrange image 排列影像 + Bring forward 上移一層 + Bring to front 移到最上層 + Crop 裁切 + Remove crop 移除裁切 + Send backward 下移一層 + Send to back 移到最下層 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @@ -2529,3 +2560,10 @@ animation effects Off 關 Options for this reply 此回覆的選項 Export value 匯出值 Show Page Edge Shadows 顯示頁面邊緣陰影 + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… 搜尋… + Image pasted 已貼上影像 + No image on clipboard to paste 剪貼簿中沒有可貼上的影像 + Pinned {tool} to Home 已將 {tool} 釘選至首頁 + Unpinned {tool} from Home 已從首頁取消釘選 {tool} diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs new file mode 100644 index 0000000000..518bea0223 --- /dev/null +++ b/crates/ui-egui/src/image_engine.rs @@ -0,0 +1,578 @@ +//! Image manipulation engine for non-destructive crop, anchor-based resize, and movement. +//! +//! Contributor-original clean-room design with zero-panic safety and lockstep pixel-scale invariance. + +use egui::{Color32, Pos2, Rect, Stroke, Vec2, pos2}; + +use crate::edit_text_ui::ImageHandle; + +// ============================================================================ +// 1. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP +// ============================================================================ + +/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects. +pub const MIN_RECT_SIZE: f32 = 10.0; + +/// **Anchor-Based Resize System** +/// +/// Recalculates the screen bounding box by locking the diagonally opposite point/edge as +/// a fixed anchor. +/// +/// Math breakdown: +/// - Corner handles: The diagonally opposite corner is fixed in both X and Y. +/// Width and height are calculated relative to that anchor, clamped to >= `MIN_RECT_SIZE`, +/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate. +/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is +/// completely unaffected. +pub fn apply_resize(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { + return image_rect; + } + let mut min = image_rect.min; + let mut max = image_rect.max; + + match handle { + // Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y) + ImageHandle::TopLeft => { + let anchor = max; + let target = min + delta; + let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE); + let new_h = (anchor.y - target.y).max(MIN_RECT_SIZE); + min.x = anchor.x - new_w; + min.y = anchor.y - new_h; + } + + // Dragging TopCenter: Fixed Anchor = Bottom edge (max.y) + ImageHandle::TopCenter => { + let anchor_y = max.y; + let target_y = min.y + delta.y; + let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE); + min.y = anchor_y - new_h; + } + + // Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y) + ImageHandle::TopRight => { + let anchor_x = min.x; + let anchor_y = max.y; + let target_x = max.x + delta.x; + let target_y = min.y + delta.y; + let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE); + let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE); + max.x = anchor_x + new_w; + min.y = anchor_y - new_h; + } + + // Dragging RightCenter: Fixed Anchor = Left edge (min.x) + ImageHandle::RightCenter => { + let anchor_x = min.x; + let target_x = max.x + delta.x; + let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE); + max.x = anchor_x + new_w; + } + + // Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y) + ImageHandle::BottomRight => { + let anchor = min; + let target = max + delta; + let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE); + let new_h = (target.y - anchor.y).max(MIN_RECT_SIZE); + max.x = anchor.x + new_w; + max.y = anchor.y + new_h; + } + + // Dragging BottomCenter: Fixed Anchor = Top edge (min.y) + ImageHandle::BottomCenter => { + let anchor_y = min.y; + let target_y = max.y + delta.y; + let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE); + max.y = anchor_y + new_h; + } + + // Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y) + ImageHandle::BottomLeft => { + let anchor_x = max.x; + let anchor_y = min.y; + let target_x = min.x + delta.x; + let target_y = max.y + delta.y; + let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE); + let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE); + min.x = anchor_x - new_w; + max.y = anchor_y + new_h; + } + + // Dragging LeftCenter: Fixed Anchor = Right edge (max.x) + ImageHandle::LeftCenter => { + let anchor_x = max.x; + let target_x = min.x + delta.x; + let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE); + min.x = anchor_x - new_w; + } + } + + Rect::from_min_max(min, max) +} + +/// **Anchor-Based Proportional Resize System** +/// +/// Recalculates the screen bounding box by locking the diagonally opposite point as +/// a fixed anchor while preserving the aspect ratio. +/// +/// For corner handles, uses the axis with the larger relative change to scale both +/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`. +/// For mid-edge handles, delegates to `apply_resize`. +pub fn apply_resize_proportional(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { + return image_rect; + } + let w0 = image_rect.width().max(1.0); + let h0 = image_rect.height().max(1.0); + + match handle { + ImageHandle::TopLeft => { + let horizontal = -delta.x / w0; + let vertical = -delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max) + } + ImageHandle::TopRight => { + let horizontal = delta.x / w0; + let vertical = -delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y)) + } + ImageHandle::BottomLeft => { + let horizontal = -delta.x / w0; + let vertical = delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h)) + } + ImageHandle::BottomRight => { + let horizontal = delta.x / w0; + let vertical = delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(image_rect.min, Pos2::new(image_rect.min.x + w, image_rect.min.y + h)) + } + mid => apply_resize(image_rect, mid, delta), + } +} + +/// Trim a screen rectangle and its UVs together, keeping the opposite edge fixed. +/// The pixel scale stays unchanged, and each edge stops at the texture boundary. +pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: ImageHandle, delta: Vec2) -> (Rect, Rect) { + if !delta.is_finite() || !image_rect.is_finite() || !uv_rect.is_finite() || !image_rect.is_positive() || !uv_rect.is_positive() { + return (image_rect, uv_rect); + } + let scale = image_rect.size() / uv_rect.size(); + if !scale.is_finite() || scale.x <= 0.0 || scale.y <= 0.0 { + return (image_rect, uv_rect); + } + let minimum = (egui::vec2(MIN_RECT_SIZE, MIN_RECT_SIZE) / scale).min(uv_rect.size() * 0.95); + let mut uv = uv_rect; + if matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) { + uv.min.x = (uv.min.x + delta.x / scale.x).clamp(0.0, (uv.max.x - minimum.x).max(0.0)); + } else if matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) { + uv.max.x = (uv.max.x + delta.x / scale.x).clamp((uv.min.x + minimum.x).min(1.0), 1.0); + } + if matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) { + uv.min.y = (uv.min.y + delta.y / scale.y).clamp(0.0, (uv.max.y - minimum.y).max(0.0)); + } else if matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) { + uv.max.y = (uv.max.y + delta.y / scale.y).clamp((uv.min.y + minimum.y).min(1.0), 1.0); + } + // Reconstruct from the fixed opposite edge, rather than adding a nearly full-width + // shift to the moved edge. Calculate in f64 so each screen coordinate rounds only + // once to f32; small crops far from the origin otherwise lose multiple ulps. + let span = |min: f32, max: f32| f64::from(max) - f64::from(min); + let width = span(image_rect.min.x, image_rect.max.x) * span(uv.min.x, uv.max.x) / span(uv_rect.min.x, uv_rect.max.x); + let height = span(image_rect.min.y, image_rect.max.y) * span(uv.min.y, uv.max.y) / span(uv_rect.min.y, uv_rect.max.y); + let mut screen = image_rect; + if uv.min.x != uv_rect.min.x { + screen.min.x = (f64::from(image_rect.max.x) - width) as f32; + } else if uv.max.x != uv_rect.max.x { + screen.max.x = (f64::from(image_rect.min.x) + width) as f32; + } + if uv.min.y != uv_rect.min.y { + screen.min.y = (f64::from(image_rect.max.y) - height) as f32; + } else if uv.max.y != uv_rect.max.y { + screen.max.y = (f64::from(image_rect.min.y) + height) as f32; + } + if !screen.is_finite() || !screen.is_positive() { + return (image_rect, uv_rect); + } + (screen, uv) +} + +// ============================================================================ +// 2. VISUAL RENDERING HELPERS +// ============================================================================ + +/// Vibrant amber/orange color for Crop Mode. +pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34); + +/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle. +pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) { + let w = rect.width(); + let h = rect.height(); + if w <= 0.0 || h <= 0.0 { + return; + } + + let x1 = rect.min.x + w / 3.0; + let x2 = rect.min.x + (2.0 * w) / 3.0; + painter.line_segment([pos2(x1, rect.min.y), pos2(x1, rect.max.y)], stroke); + painter.line_segment([pos2(x2, rect.min.y), pos2(x2, rect.max.y)], stroke); + + let y1 = rect.min.y + h / 3.0; + let y2 = rect.min.y + (2.0 * h) / 3.0; + painter.line_segment([pos2(rect.min.x, y1), pos2(rect.max.x, y1)], stroke); + painter.line_segment([pos2(rect.min.x, y2), pos2(rect.max.x, y2)], stroke); +} + +/// Renders a circular handle with shadow, fill, and accent stroke using the image tool accent. +pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_hovered: bool, crop_mode: bool, accent_color: Color32) { + let base_radius = 6.5; + let circle_radius = if is_active { + base_radius + 2.0 + } else if is_hovered { + base_radius + 1.2 + } else { + base_radius + }; + + let fill_color = if crop_mode { + if is_active || is_hovered { Color32::from_rgb(255, 230, 150) } else { Color32::from_rgb(255, 255, 255) } + } else if is_active || is_hovered { + Color32::from_rgb(210, 235, 255) + } else { + Color32::from_rgb(255, 255, 255) + }; + + // Draw handle shadow/glow + painter.circle_filled(center, circle_radius + 1.5, Color32::from_rgba_unmultiplied(0, 0, 0, 80)); + + // Draw handle circle + painter.circle_filled(center, circle_radius, fill_color); + painter.circle_stroke(center, circle_radius, Stroke::new(1.8, accent_color)); +} + +// ============================================================================ +// 3. UNIT TESTS: MATHEMATICAL VERIFICATION +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + + // Verify scale in screen space: dividing an endpoint's rounding error by a tiny + // retained UV span would magnify it and make a fixed scale tolerance invalid. + fn crop_scale_preserved(before: Rect, uv_before: Rect, after: Rect, uv_after: Rect) -> bool { + let axis = |lo: f32, hi: f32, u0: f32, u1: f32, new_lo: f32, new_hi: f32, v0: f32, v1: f32| { + let span = f64::from(hi) - f64::from(lo); + let expected = span * (f64::from(v1) - f64::from(v0)) / (f64::from(u1) - f64::from(u0)); + let actual = f64::from(new_hi) - f64::from(new_lo); + // One moved endpoint rounds once to f32. EPSILON * coordinate magnitude + // bounds one ulp (conservatively twice the nearest-rounding error). + // The f64 term also covers the two arithmetic operations above. + let magnitude = f64::from(new_lo).abs().max(f64::from(new_hi).abs()).max(expected.abs()); + let bound = f64::from(f32::EPSILON) * magnitude + 4.0 * f64::EPSILON * span.abs(); + (actual - expected).abs() <= bound + }; + axis(before.min.x, before.max.x, uv_before.min.x, uv_before.max.x, after.min.x, after.max.x, uv_after.min.x, uv_after.max.x) + && axis(before.min.y, before.max.y, uv_before.min.y, uv_before.max.y, after.min.y, after.max.y, uv_after.min.y, uv_after.max.y) + } + + #[test] + fn crop_scale_at_large_screen_offset() { + let screen = Rect::from_min_size(pos2(813.7157, 0.0), vec2(495.0725, 50.0)); + let uv = Rect::from_min_size(pos2(0.114936605, 0.0), vec2(0.32074833, 0.3)); + let (cropped, kept) = apply_crop(screen, uv, ImageHandle::BottomLeft, vec2(1255.676, 0.0)); + let width = (f64::from(screen.max.x) - f64::from(screen.min.x)) * (f64::from(kept.max.x) - f64::from(kept.min.x)) + / (f64::from(uv.max.x) - f64::from(uv.min.x)); + assert_eq!(cropped.min.x, (f64::from(screen.max.x) - width) as f32, "moved edge rounds once from the fixed anchor"); + assert_eq!(cropped.max.x, screen.max.x, "opposite horizontal edge stays fixed"); + assert_eq!(cropped.min.y, screen.min.y, "opposite vertical edge stays fixed"); + assert!(crop_scale_preserved(screen, uv, cropped, kept)); + let stretched = Rect::from_min_max(cropped.min - vec2(0.1, 0.0), cropped.max); + assert!(!crop_scale_preserved(screen, uv, stretched, kept), "a visible stretch must exceed the rounding bound"); + } + + #[test] + fn test_resize_bottom_right_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::BottomRight, vec2(50.0, 25.0)); + assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed"); + assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta"); + } + + #[test] + fn test_resize_top_left_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopLeft, vec2(20.0, 15.0)); + assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed"); + assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta"); + } + + #[test] + fn test_resize_clamping_prevents_inversion() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopLeft, vec2(500.0, 500.0)); + assert_eq!(res.max, pos2(200.0, 200.0)); + assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE)); + assert!(res.width() >= MIN_RECT_SIZE); + assert!(res.height() >= MIN_RECT_SIZE); + } + + #[test] + fn test_resize_top_right_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopRight, vec2(10.0, -10.0)); + assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed"); + assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed"); + assert_eq!(res.max.x, 310.0); + assert_eq!(res.min.y, 90.0); + } + + #[test] + fn test_resize_bottom_left_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::BottomLeft, vec2(-15.0, 20.0)); + assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed"); + assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed"); + assert_eq!(res.min.x, 85.0); + assert_eq!(res.max.y, 220.0); + } + + #[test] + fn test_resize_mid_edges() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + + let tm = apply_resize(r, ImageHandle::TopCenter, vec2(50.0, -30.0)); + assert_eq!(tm.min.x, 100.0, "X min unaffected"); + assert_eq!(tm.max.x, 300.0, "X max unaffected"); + assert_eq!(tm.max.y, 200.0, "Y max anchor locked"); + assert_eq!(tm.min.y, 70.0); + + let rm = apply_resize(r, ImageHandle::RightCenter, vec2(40.0, 50.0)); + assert_eq!(rm.min.x, 100.0, "X min anchor locked"); + assert_eq!(rm.max.x, 340.0); + assert_eq!(rm.min.y, 100.0, "Y unaffected"); + assert_eq!(rm.max.y, 200.0, "Y unaffected"); + } + + #[test] + fn test_crop_lockstep_ratio_preservation() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let initial_px_per_uv_x = screen.width() / uv.width(); + let initial_px_per_uv_y = screen.height() / uv.height(); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(-80.0, 0.0)); + + assert_eq!(new_screen.min.x, 100.0, "Left edge anchored"); + assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px"); + assert_eq!(new_screen.width(), 320.0); + + assert_eq!(new_uv.min.x, 0.0); + assert!((new_uv.max.x - 0.8).abs() < 1e-4); + + let new_px_per_uv_x = new_screen.width() / new_uv.width(); + assert!((new_px_per_uv_x - initial_px_per_uv_x).abs() < 1e-4, "Pixel scale must remain invariant in crop mode"); + + let new_px_per_uv_y = new_screen.height() / new_uv.height(); + assert!((new_px_per_uv_y - initial_px_per_uv_y).abs() < 1e-4, "Pixel scale Y must remain invariant"); + } + + #[test] + fn test_crop_left_edge_anchoring() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(50.0, 0.0)); + + assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored"); + assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px"); + assert_eq!(new_screen.width(), 350.0); + + assert!((new_uv.min.x - 0.125).abs() < 1e-4); + assert_eq!(new_uv.max.x, 1.0); + } + + #[test] + fn test_crop_uncrop_clamped_at_zero_and_one() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(-100.0, 0.0)); + assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0"); + assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position"); + + let (new_screen2, new_uv2) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(100.0, 0.0)); + assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0"); + assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position"); + } + + #[test] + fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() { + let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100 + // TopRight corner moved horizontally by -25px (from x=200 to x=175) + let resized = apply_resize_proportional(rect, ImageHandle::TopRight, vec2(-25.0, 0.0)); + assert_eq!(resized.min.x, 100.0, "Left anchor preserved"); + assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved"); + assert_eq!(resized.width(), 75.0, "Width scaled to 75%"); + assert_eq!(resized.height(), 75.0, "Height scaled to 75% proportionally"); + assert_eq!(resized.max.x, 175.0); + assert_eq!(resized.min.y, 125.0); + } + + #[test] + fn test_apply_resize_proportional_bottom_right_anchor_locked() { + let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200 + // BottomRight moved down/right by (20px, 40px) + let resized = apply_resize_proportional(rect, ImageHandle::BottomRight, vec2(20.0, 40.0)); + assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked"); + assert_eq!(resized.width(), 120.0); + assert_eq!(resized.height(), 240.0); + } + + #[cfg(test)] + mod proptests { + use super::*; + use proptest::prelude::*; + + fn arb_handle() -> impl Strategy { + prop_oneof![ + Just(ImageHandle::TopLeft), + Just(ImageHandle::TopCenter), + Just(ImageHandle::TopRight), + Just(ImageHandle::RightCenter), + Just(ImageHandle::BottomRight), + Just(ImageHandle::BottomCenter), + Just(ImageHandle::BottomLeft), + Just(ImageHandle::LeftCenter), + ] + } + + proptest! { + #[test] + fn prop_apply_resize_never_inverts_and_respects_min_size( + min_x in -5000.0f32..5000.0, + min_y in -5000.0f32..5000.0, + w in 10.0f32..2000.0, + h in 10.0f32..2000.0, + handle in arb_handle(), + dx in -10000.0f32..10000.0, + dy in -10000.0f32..10000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.min.x <= res.max.x); + prop_assert!(res.min.y <= res.max.y); + } + + #[test] + fn prop_apply_resize_proportional_preserves_aspect_ratio( + min_x in -2000.0f32..2000.0, + min_y in -2000.0f32..2000.0, + w in 20.0f32..1000.0, + h in 20.0f32..1000.0, + handle in arb_handle(), + dx in -5000.0f32..5000.0, + dy in -5000.0f32..5000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize_proportional(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + + let is_corner = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight); + if is_corner { + let orig_ratio = w / h; + let new_ratio = res.width() / res.height(); + prop_assert!((orig_ratio - new_ratio).abs() <= 1e-3 * orig_ratio.max(1.0), "orig: {orig_ratio}, new: {new_ratio}"); + } + } + + #[test] + fn prop_apply_crop_uv_clamped( + min_x in 0.0f32..1000.0, + min_y in 0.0f32..1000.0, + w in 50.0f32..500.0, + h in 50.0f32..500.0, + uv_u0 in 0.0f32..0.4, + uv_v0 in 0.0f32..0.4, + uv_w in 0.3f32..0.5, + uv_h in 0.3f32..0.5, + handle in arb_handle(), + dx in -2000.0f32..2000.0, + dy in -2000.0f32..2000.0, + ) { + let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h)); + let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy)); + + prop_assert!(new_screen.is_finite()); + prop_assert!(new_uv.is_finite()); + prop_assert!(new_uv.min.x >= 0.0 - 1e-5); + prop_assert!(new_uv.min.y >= 0.0 - 1e-5); + prop_assert!(new_uv.max.x <= 1.0 + 1e-5); + prop_assert!(new_uv.max.y <= 1.0 + 1e-5); + prop_assert!(new_uv.width() > 0.0); + prop_assert!(new_uv.height() > 0.0); + + if !matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) { + prop_assert_eq!(new_screen.min.x, screen.min.x, "stationary left edge moved"); + } + if !matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) { + prop_assert_eq!(new_screen.max.x, screen.max.x, "stationary right edge moved"); + } + if !matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) { + prop_assert_eq!(new_screen.min.y, screen.min.y, "stationary top edge moved"); + } + if !matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) { + prop_assert_eq!(new_screen.max.y, screen.max.y, "stationary bottom edge moved"); + } + + prop_assert!(crop_scale_preserved(screen, uv, new_screen, new_uv), + "screen/UV scale changed beyond endpoint rounding: {screen:?} {uv:?} => {new_screen:?} {new_uv:?}"); + } + + #[test] + fn prop_quad_contains_never_panics( + q0_x in -1000.0f32..1000.0, + q0_y in -1000.0f32..1000.0, + q1_x in -1000.0f32..1000.0, + q1_y in -1000.0f32..1000.0, + q2_x in -1000.0f32..1000.0, + q2_y in -1000.0f32..1000.0, + q3_x in -1000.0f32..1000.0, + q3_y in -1000.0f32..1000.0, + px in -2000.0f32..2000.0, + py in -2000.0f32..2000.0, + ) { + let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)]; + let p = pos2(px, py); + let _ = crate::edit_text_ui::quad_contains(quad, p); + } + } + + #[test] + fn degenerate_quad_does_not_contain_points() { + let zero_quad = [pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0)]; + assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(10.0, 10.0))); + assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(0.0, 0.0))); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d9b77dcc3f..c25dae98e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ macro_rules! tl_ctx { } pub mod canvas; mod chrome; +mod image_engine; pub use combine_grid::ThumbState as CombineThumb; pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod commands; @@ -45,15 +46,17 @@ mod menus; pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; } +pub use content_ui::TextDraft; pub use create_ui::Clip; pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep}; mod bidi; mod dialogs; -pub use edit_text_ui::CropMode; +pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; mod home; +mod hotbar; mod panels; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -77,6 +80,9 @@ mod state; mod tools; mod workflows; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod os_clipboard; + #[cfg(not(target_arch = "wasm32"))] pub(crate) use app::same_file; pub(crate) use app::{MAX_AUTHOR_CHARS, copy_refused, date_text_for_pdf, print_refused}; @@ -114,6 +120,7 @@ use panels::search_ui; pub use panels::side_panels; pub use persist::last_session; pub use persist::last_view; +pub use persist::paper; use persist::recovery; pub use persist::units; #[cfg(not(target_arch = "wasm32"))] @@ -140,7 +147,7 @@ use state::CopyPage; use state::OpenRequest; pub use state::{ CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, - PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, + PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -259,6 +266,8 @@ pub struct PdfCraftApp { pub view_defaults: canvas::ViewDefaults, /// The unit page sizes and margins are shown in (Preferences ▸ Documents and view). pub units: units::Unit, + /// The paper Create ▸ Blank page makes (#5644): the system region's. + pub blank_paper: paper::Paper, /// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid /// pattern (the session keeps the last valid one). pub date_format_draft: Option, @@ -286,6 +295,7 @@ pub struct PdfCraftApp { full_screen_tool: Option, /// Comment author, per-tool colours and widths, pin. pub comment_prefs: comments::CommentPrefs, + pub(crate) hotbar: hotbar::Hotbar, /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, @@ -341,6 +351,8 @@ pub struct PdfCraftApp { /// tab coming to the front (Home, a document, Combine files) puts the view away. all_tools_tab: (Option, bool), pub recent: Vec, + /// How many recent files to keep, 0 for no list (Preferences ▸ Documents and view, #5572). + pub recent_limit: usize, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, /// Restore last view settings when reopening documents (Preferences ▸ Documents and view; @@ -380,6 +392,12 @@ pub struct PdfCraftApp { open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, + /// The recent files restored from settings, checked off the UI thread (#5682). + recent_check: persist::recent_check::RecentCheck, + /// Tools pinned to Home, in user-defined order. + pub pinned_tools: Vec, + /// Search query for the Pin a tool selection dialog. + pub pin_tool_search: String, pub toast: Option<(String, f64)>, /// The form script message the toast shows now, if it shows one: drawn with its title and /// icon (#4293). @@ -545,8 +563,6 @@ pub struct PdfCraftApp { pub system_clipboard: bool, /// What ⌘V reads instead of the system clipboard (tests). pub clipboard_override: Option, - /// V is down as a plain key press (see [`Self::paste_key_released`]). - v_pressed: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. diff --git a/crates/ui-egui/src/os_clipboard.rs b/crates/ui-egui/src/os_clipboard.rs new file mode 100644 index 0000000000..9f68bd957b --- /dev/null +++ b/crates/ui-egui/src/os_clipboard.rs @@ -0,0 +1,28 @@ +//! The one way to the operating system's clipboard. +//! +//! The system clipboard is a single process-wide resource: on Windows, two threads that +//! open it at once can corrupt the heap. Every access goes through [`with`], which holds a +//! lock across the whole open, use and close, so parallel callers (test threads, workers) +//! take turns instead of racing. + +use std::sync::{Mutex, PoisonError}; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Opens the system clipboard, runs `f` on it and closes it, with no other thread inside. +pub(crate) fn with(f: impl FnOnce(&mut arboard::Clipboard) -> Result) -> Result { + let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + // Declared after the guard, so it's closed before the lock is released. + let mut clipboard = arboard::Clipboard::new()?; + f(&mut clipboard) +} + +/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists. +pub(crate) fn has_text() -> bool { + with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty()) +} + +/// The system clipboard as image paste sees it; text takes precedence over a cached image. +pub(crate) fn for_image_paste() -> Option { + if has_text() { Some(crate::create_ui::Clip::Text(String::new())) } else { crate::create_ui::read_clipboard() } +} diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index c549ed8e3e..d54daf5717 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -241,6 +241,10 @@ pub(crate) fn show( } let mut page = usize::MAX; let mut group: Option = None; + // A card (or page header) out of view that was measured at this width only takes up its + // height: laying out hundreds of them every frame put the panel over the frame budget + // (#5488). Shorter lists are laid out whole, so every card stays in the accessibility tree. + let virtual_list = roots.len() > LAY_OUT_WHOLE; for a in roots { if sort != SortBy::Page { let g = group_of(a); @@ -252,48 +256,118 @@ pub(crate) fn show( } } else if a.page != page { page = a.page; - let n = page_counts.get(&page).copied().unwrap_or_default(); - let open = !view.comments.collapsed_pages.contains(&page); - ui.add_space(6.0); - let clicked = ui - .horizontal(|ui| { - let chevron = if open { "chevron-down" } else { "chevron-right" }; - let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked(); - let label = ui.add( - egui::Label::new( - egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)])) - .font(theme::semibold(12.5)) - .color(t.text_muted), - ) - .sense(Sense::click()), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()) - }); - toggle || label.clicked() - }) - .inner; - if clicked { - if open { - view.comments.collapsed_pages.push(page); - } else { - view.comments.collapsed_pages.retain(|&c| c != page); + // Per document: two documents shown at the same width don't share heights. + let header = ui.scope_id().with(("comment-page-header", view.id, page)); + if !(virtual_list && skip_if_hidden(ui, header)) { + let top = ui.cursor().min.y; + page_header(ui, t, info, page, page_counts.get(&page).copied().unwrap_or_default(), &mut view.comments.collapsed_pages); + if virtual_list { + remember_height(ui, header, top); } } - ui.add_space(2.0); } if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) { continue; } + let key = (a.page, a.index); let thread = replies_of(a); + let selected = view.comments.selected == Some(key); + // A height is kept for what the card showed when it was measured: its document, what the + // thread says, whether it was the selected card (which draws its menu and editor) and + // whether an editor was open on it. An + // edit, undo or redo that changes the card measures it afresh; other cards keep theirs. + // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on + // #5583): a kept height would show it at its size before the editor opened. + let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i))); + let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing)); + // The selected card is always drawn, so it can scroll itself into view and keep its menu + // and editor. + if virtual_list && !selected && !editing && skip_if_hidden(ui, id) { + continue; + } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); + let top = ui.cursor().min.y; if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) { edit = Some(e); } + if virtual_list { + remember_height(ui, id, top); + } } edit } +/// The "Page N" group header with its fold chevron and comment count `n`; a click folds or +/// unfolds the page. +fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: usize, collapsed: &mut Vec) { + let open = !collapsed.contains(&page); + ui.add_space(6.0); + let clicked = ui + .horizontal(|ui| { + let chevron = if open { "chevron-down" } else { "chevron-right" }; + let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked(); + let label = ui.add( + egui::Label::new( + egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", info.pages.get(page).map_or("?", |p| p.label.as_str()))])) + .font(theme::semibold(12.5)) + .color(t.text_muted), + ) + .sense(Sense::click()), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())); + toggle || label.clicked() + }) + .inner; + ui.add_space(2.0); + if clicked { + if open { + collapsed.push(page); + } else { + collapsed.retain(|&c| c != page); + } + } +} + +/// What a card's height depends on in the document: the comment and its replies as shown (their +/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out +/// alike at one width. +fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for x in std::iter::once(a).chain(thread.iter().copied()) { + (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h); + x.color.map(|c| c.map(f32::to_bits)).hash(&mut h); + } + h.finish() +} + +/// Comment lists up to this long are laid out whole; longer ones lay out only what is in view. +const LAY_OUT_WHOLE: usize = 100; + +/// For a part of the list (a card or a page header) that was measured at this width and would +/// fall out of view: takes up its height without laying it out, and returns `true`. +fn skip_if_hidden(ui: &mut egui::Ui, id: egui::Id) -> bool { + let width = ui.available_width(); + let Some(height) = ui.data(|d| d.get_temp::<(f32, f32)>(id)).filter(|(w, _)| (w - width).abs() < 0.5).map(|(_, h)| h) else { + return false; + }; + if ui.is_rect_visible(egui::Rect::from_min_size(ui.cursor().min, vec2(width, height))) { + return false; + } + ui.allocate_space(vec2(width, height)); + true +} + +/// Records the height part `id` took since the cursor was at `top`, for [`skip_if_hidden`]: less +/// the spacing `allocate_space` adds back after it. +fn remember_height(ui: &egui::Ui, id: egui::Id, top: f32) { + let height = ui.cursor().min.y - top - ui.spacing().item_spacing.y; + if height.is_finite() && height > 0.0 { + let width = ui.available_width(); + ui.data_mut(|d| d.insert_temp(id, (width, height))); + } +} + /// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment /// actions; returns an edit to apply. struct CardContext<'a> { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 57d8870397..2c2fb7a09a 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -268,11 +268,27 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { .into_iter() .map(|f| (f.resource, f.face)) .collect(); - let shown = ed.font_shown(&sources); + let fmt = ed.selection_formatting(&sources); + ed.sync_selection_look(&fmt); + let shown = fmt.font_shown; + let mixed_size = fmt.size.is_none(); // A selection aligns the rows it touches; the caret alone, the whole paragraph. let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false }; - let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs)); + let out = crate::format_text_ui::panel( + ui, + t, + &mut ed.look, + Some(&mut ed.extras), + Fonts { sources: Some(&sources), shown, mixed_size }, + Some(paragraphs), + ); + if out.size_committed { + ed.choose_size(ed.look.size); + } let mut changed = out.changed; + if let Some(script) = out.script { + changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script); + } match out.font { Some(FontChoice::KeepSource) => { ed.keep_source_font(); @@ -307,7 +323,13 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } let mut style = draft.style.clone(); let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true }; - added_text_format(ui, t, &mut style, Some(paragraphs)); + let out = added_text_format(ui, t, &mut style, Some(paragraphs)); + if let Some(script) = out.script { + let mut toggled = draft.clone(); + if crate::edit_text_ui::toggle_script(&mut toggled.text, &mut toggled.selection, script) { + app.views[i].content.draft = Some(toggled); + } + } if style != draft.style { app.restyle_draft(i, style); } @@ -365,12 +387,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// Format text for added text: the standard families, size, colour, style and alignment /// (of the lines `paragraphs` selects, while the text is being typed). -fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) { +fn added_text_format( + ui: &mut egui::Ui, + t: &Tokens, + style: &mut pdfcraft_engine::AddedText, + paragraphs: Option, +) -> crate::format_text_ui::Outcome { let shown = style.family.label().to_string(); - let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs); + let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs); if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { style.family = family; } + out } impl PdfCraftApp { diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 7b2763bf32..38ccb96b16 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -3,12 +3,13 @@ //! horizontal scale and character spacing. One panel serves a paragraph being edited (with the //! fonts already on its page and the spacing controls) and added text (the standard families, //! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists, -//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown +//! paragraph spacing, or line spacing and scale for added text) is shown //! disabled and says so. //! //! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::text_script::Script; use pdfcraft_engine::{AddedText, FontFamily, TextAlign}; use crate::edit_text_ui::Extras; @@ -38,6 +39,7 @@ pub(crate) struct Fonts<'a> { pub sources: Option<&'a [(String, String)]>, /// The name the list shows for the current choice. pub shown: String, + pub mixed_size: bool, } /// Text being typed in, for the alignment buttons: with some of it selected, they show and set the @@ -85,7 +87,10 @@ impl Paragraphs<'_> { #[derive(Default)] pub(crate) struct Outcome { pub changed: bool, + pub size_committed: bool, pub font: Option, + /// Superscript or Subscript was clicked, for the selected paragraphs' text. + pub script: Option"##; + let form = layout_xml(&form_of_draws(master, &line_draw("1in", "1in", ""))).unwrap(); + let held = turned(&form.pages[0]); + assert_eq!(held.len(), 1, "{:?}", form.pages[0].items); + let text: Vec<&str> = held[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.as_str()) } else { None }).collect(); + assert_eq!(text.concat().trim(), "of 1", "{text:?}"); +} + +/// A link in turned text is clicked where the text is drawn, whichever way it turned: the box it +/// has unturned, turned about the draw's anchor. +#[test] +fn a_link_turns_with_its_text() { + let rect_at = |rotate: &str| { + let draw = format!( + r#"

a link in turned text

"# + ); + let (doc, page) = written(&form_of_draws("", &draw)); + let annots = page.get(b"Annots").map(|a| doc.resolve(a)).expect("the link"); + let annot = doc.get(annots.as_array().unwrap()[0].as_ref().unwrap()); + let rect: Vec = annot.as_dict().unwrap().get(b"Rect").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + rect + }; + let upright = rect_at(""); + let (ax, ay) = (18.0 + 72.0, 792.0 - 18.0 - 216.0); + let (left, bottom, right, top) = (upright[0] - ax, upright[1] - ay, upright[2] - ax, upright[3] - ay); + assert!(left >= -0.01 && top <= 0.01 && right - left > 3.0 * (top - bottom), "it stands right and down of its anchor: {upright:?}"); + for (rotate, want) in [ + ("90", [ax - top, ay + left, ax - bottom, ay + right]), + ("180", [ax - right, ay - top, ax - left, ay - bottom]), + ("270", [ax + bottom, ay - right, ax + top, ay - left]), + ] { + let got = rect_at(&format!(r#"rotate="{rotate}""#)); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01), "{rotate}: {got:?} for {want:?}"); + } +} + +/// Only a draw placed by position turns: a field, and a draw in a flow, stand as they did. +#[test] +fn only_positioned_draws_turn() { + let field = r#""#; + let form = layout_xml(&form_of_draws("", field)).unwrap(); + assert!(turned(&form.pages[0]).is_empty()); + assert_eq!(widgets(&form.pages[0]).len(), 1); + let flowed = form_of_draws("", &line_draw("1in", "1in", r#"rotate="90""#)) + .replace(r#""#, r#""#); + let form = layout_xml(&flowed).unwrap(); + assert!(turned(&form.pages[0]).is_empty(), "{:?}", form.pages[0].items); + assert!(form.pages[0].items.iter().any(|i| matches!(i, Item::Line { .. })), "it is still drawn"); + // A draw that shows nothing has nothing to turn. + let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", r#"rotate="90" presence="invisible""#))).unwrap(); + assert!(form.pages[0].items.is_empty(), "{:?}", form.pages[0].items); +} + +/// A block carried over to a content area further across takes a turned draw with it, point and +/// items both. +#[test] +fn a_turned_draw_moves_across_with_its_content_area() { + let xdp = form_of_draws("", &line_draw("1in", "6in", r#"rotate="90""#)) + .replace( + r#""#, + r#""#, + ) + .replace(r#"h="2.5in""#, r#"h="7in""#); + let form = layout_xml(&xdp).unwrap(); + let moved = form.pages.iter().flat_map(turned).next().expect("turned"); + let Item::Line { from, .. } = &moved.items[0] else { panic!("not a line") }; + assert_eq!(*from, moved.about); + assert!((moved.about.0 - (4.0 + 1.0) * 72.0).abs() < 0.01, "{:?}", moved.about); +} + +/// A positioned subform with no height grows to hold what it holds as it stands, so what comes next +/// neither runs through a turned draw nor leaves the room its upright box would have had. +#[test] +fn a_container_grows_to_a_turned_draw_as_it_stands() { + let next = r#"next"#; + let baseline = |rotate: &str| { + let xdp = form_of_draws("", &line_draw("1in", "1in", rotate)).replace(r#" h="2.5in""#, "").replace("", next); + let form = layout_xml(&xdp).unwrap(); + assert_eq!(form.pages.len(), 1); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block") + }; + // Upright the box ends 0.4in below the anchor, and the next block starts there. + let upright = baseline(""); + for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] { + let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8); + assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}"); + } +} + +/// A subform with no width is as wide as what it holds as it stands, whichever way that turned, +/// so a neighbour in a row doesn't stand off from it or over it. +#[test] +fn a_container_is_as_wide_as_a_turned_draw_stands() { + let row = |rotate: &str| { + let xdp = form_of_draws( + "", + &format!( + r#"{}beside"#, + line_draw("1in", "1in", rotate) + ), + ) + .replace(r#""#, r#""#); + let form = layout_xml(&xdp).unwrap(); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour") + }; + let upright = row(""); + // Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again. + for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] { + let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } + // From a bottom-right anchor the box starts behind it, and the same turns take it the other way. + for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] { + let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 008cae903d..b5beb009b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,10 +71,10 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod ## File I/O - PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and - logged), all standard-security revisions. Image codecs (DCT, JPX, JBIG2) decode through the - bootstrap renderer; CCITT decodes through `pdfcraft-filters` (`decode_ccitt`, behind - `Document::decoded_image`) for export, redaction and the optimizer, and through the renderer - for display. Export ▸ Images decodes JPEG itself. + logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap + renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt`, `decode_jbig2`, + behind `Document::decoded_image`) for export, redaction and the optimizer, and through the + renderer for display. Export ▸ Images decodes JPEG itself. - PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when combining. - Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index b62ecbc171..aefc959698 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -2,7 +2,7 @@ > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: redaction decodes CCITT fax images, Group 3 on bytes aside; earlier, §7.4: CCITT decoding in `pdfcraft-filters`) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -23,7 +23,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 |---|---|---|---|---| | §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | | §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | -| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 and lossless JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; CCITT decoding in `pdfcraft-filters` (`decode_ccitt`, through `hayro-ccitt`) for export, redaction and the Optimizer, which encodes Group 4 (`optimize.mono-compression`); lossless JBIG2 generic-region or exact-copy symbol encoding and image export; no lossy substitution | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 and lossless JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; CCITT decoding in `pdfcraft-filters` (`decode_ccitt`, through `hayro-ccitt`) for export, redaction and the Optimizer, which encodes Group 4 (`optimize.mono-compression`); JBIG2 decoding there too (`decode_jbig2`, through `hayro-jbig2`) for export and redaction; lossless JBIG2 generic-region or exact-copy symbol encoding; no lossy substitution | | §7.5.8 | Linearization | R W | R only | `core.linearization` planned | | §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; written files open in Acrobat DC since #836 (#774: Acrobat needs the catalog outside object streams) | | §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | @@ -132,6 +132,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | | 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | | 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | | 2026-10-26 | minor | §7.4: CCITT Group 4 encoding (`fax`) in the Optimizer | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4319796163..673e9053fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,13 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -217,7 +225,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -231,7 +239,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -290,6 +298,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | | 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..f950e36a94 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) |---|---|---|---| | Home view (recent files, recommended tools) | yes | yes | shipped | | All tools pane, tool sets | yes | yes, with milestone badges | shipped | -| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Quick-action toolbar, customisable | yes | customizable floating/docked hotbar, adjustable size, persistent multi-select picker | partial (`view.customize-quick-tools`; no shortcut reordering) | | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 18498399c9..cb5655fe34 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -130,8 +130,8 @@ milestone = "M1" status = "partial" commands = [] tools = [] -evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters"] -notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" +evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals", "crates/cos/src/document/tests.rs::shared_jbig2_globals_are_read_once"] +notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image, redaction and the optimizer. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] id = "core.decompression-limits" @@ -1598,8 +1598,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] -notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images and JBIG2 inside other filters, multi-format clipboard (file and bitmap at once), the web build" +evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text", "crates/engine/src/tests.rs::copy_image_copies_a_jbig2_picture_behind_another_filter", "crates/engine/src/tests.rs::copy_image_reads_the_globals_of_a_jbig2_picture"] +notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images, multi-format clipboard (file and bitmap at once), the web build" [[feature]] id = "view.rulers" @@ -1799,11 +1799,22 @@ title = "Customise the quick-action toolbar and pinned tools" area = "B" tier = "P2" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = [ + "crates/ui-egui/tests/it/pinned_tools.rs::state_operations_and_validation", + "crates/ui-egui/tests/it/pinned_tools.rs::persistence_round_trip_and_sanitization", + "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", + "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", + "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_adds_and_runs_freehand_highlight", + "crates/ui-egui/tests/it/comments.rs::hotbar_drag_moves_and_persists_position", + "crates/ui-egui/tests/it/comments.rs::hotbar_vertical_size_and_docking", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_reopen_even_with_remembered_collapsed_state", + "crates/ui-egui/tests/it/comments.rs::hotbar_full_side_strip_spans_each_edge_and_returns_to_float", +] +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Customizable document hotbar searches the command registry, adds/removes tools, resizes buttons from 24 to 64 pixels, floats or docks on any side with an optional full-side chrome strip, and persists preferences (UI control: hotbar-tools, hotbar-size, hotbar-side, hotbar-full-side). Missing: hotbar shortcut reordering and full Acrobat customization-dialog parity." [[feature]] id = "view.restriction-notice" @@ -1959,11 +1970,19 @@ title = "Superscript and subscript" area = "C" tier = "P2" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.edit_text"] +tools = ["text_script"] +evidence = [ + "crates/engine/src/text_script.rs::every_form_toggles_back_to_its_character", + "crates/engine/src/text_script.rs::text_with_characters_unicode_cannot_raise_toggles_back", + "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion", + "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection", + "crates/automation/tests/it/automation.rs::superscript_and_subscript_through_tools", + "crates/engine/src/text_script.rs::mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping", + "crates/automation/tests/it/automation.rs::mixed_script_tools_normalize_but_undo_restores_the_exact_mixture", +] +notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay unchanged and do not determine script state. Two toggles restore uniformly plain or uniformly scripted inputs. Mixed input normalizes (H₂2 → H₂₂ → H22), so a second toggle does not recover the original mixture; Undo restores the exact prior text. The mathematical minus sign U+2212 has its own script mapping, while ASCII hyphen-minus stays unchanged." [[feature]] id = "edit.paragraph-alignment" @@ -2129,8 +2148,8 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["content_update", "image_edit"] -evidence = ["crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"] -notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." +evidence = ["crates/edit/src/tests.rs::removing_a_crop_preserves_author_clips_and_works_under_outer_clip", "crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"] +notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop removes only a crop created by PdfCraft, preserving original document clips. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Grouped Form artwork cannot be cropped. Shared clips and other clipping paths from the original file remain unchanged." [[feature]] id = "edit.image-rotate-flip" @@ -2153,8 +2172,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip", "crates/automation/tests/it/automation.rs::transforming_page_images_through_tools"] +notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Headless: image_edit action move (a new box) or transform (any finite, non-flat 6-number matrix in top-left-origin points, applied after the placement: the edit a resize of a turned image makes). Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract" @@ -2186,11 +2205,17 @@ title = "Arrange objects (bring forward, send backward)" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["image_edit"] +evidence = [ + "crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop", + "crates/edit/src/tests.rs::image_arrange_refuses_crossed_scope_boundaries_without_changes", + "crates/edit/src/tests.rs::arrange_refuses_unrestorable_extended_print_state", + "crates/automation/tests/it/automation.rs::arranging_page_images_through_tools", + "crates/ui-egui/tests/it/editing.rs::duplicate_xobject_at_different_rects_arrange_and_delete_keeps_other_instance_through_menu", +] +notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and persistence. Refuses arranging across shared marked content, unbalanced graphics/marked-content boundaries, or when clipping path transforms cannot be cleanly preserved. Stacking for arbitrary vector paths and inline images remains planned." [[feature]] id = "edit.align-distribute-objects" @@ -2435,8 +2460,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer.update", "edit.header_footer.remove"] tools = ["doc_header_footer", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] -notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/mark_transaction_tests.rs::header_footer_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). recognises PdfCraft marks; Acrobat-made headers are not detected yet" [[feature]] id = "edit.header-footer-tokens" @@ -2507,8 +2532,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark.update", "edit.watermark.remove"] tools = ["doc_watermark", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/mark_transaction_tests.rs::watermark_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). " [[feature]] id = "edit.watermark-appearance" @@ -2543,8 +2568,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background.update", "edit.background.remove"] tools = ["doc_background", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop"] -notes = "Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop", "crates/edit/src/mark_transaction_tests.rs::background_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off" [[feature]] id = "edit.decoration-profiles" @@ -5320,8 +5345,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" @@ -5804,8 +5829,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax images (scans stored as Group 4) included; DCT/JPX/JBIG2 images, and Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), removed whole" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors" @@ -6968,8 +6993,8 @@ milestone = "M10" status = "shipped" commands = ["create.blank"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "" +evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/ui-egui/tests/it/create.rs::a_blank_page_is_on_the_system_regions_paper", "crates/ui-egui/src/persist/paper.rs::region_decides_the_paper", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] +notes = "The page is the system region's paper (#5644): US Letter in the US, Canada, Mexico, the Philippines and the other CLDR Letter regions, A4 elsewhere; Windows' own Region paper size (iPaperSize) first, then the system locale (macOS) or LC_ALL/LC_PAPER/LANG; Letter when the region is unknown (web). The doc_create tool keeps Letter unless given width and height. Missing: a page size or count chooser like Acrobat's New Document dialog." [[feature]] id = "create.from-html" @@ -7188,8 +7213,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2 masks are left out (image exported opaque); JPX/JBIG2/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong." +evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." [[feature]] id = "create.export-html" @@ -7384,8 +7409,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB, and indexed images that use only palette entries 0 and 1) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, ink in a separation, after the decode array; an indexed image's entry 0, its palette not read yet), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not monochrome images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, JBIG2, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base or with a table over 4 KiB, images drawn or listed where /DefaultGray or /DefaultRGB is or may be in force (an indexed image also used beyond the page and form resources read, as an annotation's appearance), images with a colour key, which can hide the dark sample, or a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" @@ -7408,8 +7433,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols"] -notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again"] +notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" @@ -9937,11 +9962,11 @@ title = "Preferences: documents (autosave interval, recent count)" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/recent_files.rs::preferences_cuts_the_list_when_it_closes_and_not_before", "crates/ui-egui/tests/it/recent_files.rs::at_zero_home_has_no_recent_section", "crates/ui-egui/tests/it/recent_files.rs::a_settings_file_can_not_ask_for_a_limit_that_is_not_one"] +notes = "Preferences ▸ Documents and view: Recent files to keep, from 0 to 50 with 12 by default (#5572). At 0 Home has no Recent section and File ▸ Open Recent is disabled; Restore the last view keeps its own record of paths under its own switch. Still missing: the autosave interval (recovery snapshots run every AUTOSAVE_SECS, 60 s) and the rest of Acrobat's Documents page." [[feature]] id = "misc.prefs-commenting" diff --git a/vendor/README.md b/vendor/README.md index 84b2546bf3..35cca11813 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,11 +39,11 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 29a74a6de8..b93b69c73c 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -166,12 +166,24 @@ fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option, cache: &Cache) -> Option { - Self::new_inner(object, cache) + fn new(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + if depth >= MAX_COLOR_SPACE_DEPTH { + warn!("colour spaces nest past the limit of {MAX_COLOR_SPACE_DEPTH} levels"); + return None; + } + + Self::new_inner(object, cache, depth) } - fn new_inner(object: Object<'_>, cache: &Cache) -> Option { + fn new_inner(object: Object<'_>, cache: &Cache, depth: u32) -> Option { if let Object::Name(name) = object { return Self::new_from_name(&name); } else if let Object::Array(color_array) = object { @@ -186,6 +198,9 @@ impl ColorSpaceType { // PdfCraft patch (48): cached by what the profile is read from (see // `IccProfileKey`), not by the stream dictionary's bytes alone. + // PdfCraft patch (74): what's kept is what the profile itself reads as. What + // it falls back to depends on how deep it was met, and kept with the profile + // a page could build a chain past the limit on spaces already read. let read = || { let data = icc_stream.raw_data(); let content = IccProfileKey::Content { @@ -194,40 +209,37 @@ impl ColorSpaceType { }; cache.get_or_insert_with(hash128(&content), || { cache.count_icc_profile_read(); - if let Some(decoded) = icc_stream.decoded().ok().as_ref() { - ICCProfile::new(decoded, num_components) - .map(|icc| { - // TODO: For SVG and PNG we can assume that the output color space is - // sRGB. If we ever implement PDF-to-PDF, we probably want to - // let the user pass the native color type and don't make this optimization - // if it's not sRGB. - if icc.is_srgb() { - Self::DeviceRgb - } else { - Self::ICCBased(icc) - } - }) - .or_else(|| { - dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache)) - }) - .or_else(|| match dict.get::(N) { - Some(1) => Some(Self::DeviceGray), - Some(3) => Some(Self::DeviceRgb), - Some(4) => Some(Self::DeviceCmyk), - _ => None, - }) - } else { - None - } + let decoded = icc_stream.decoded().ok()?; + Some(ICCProfile::new(&decoded, num_components).map(|icc| { + // TODO: For SVG and PNG we can assume that the output color space is + // sRGB. If we ever implement PDF-to-PDF, we probably want to + // let the user pass the native color type and don't make this optimization + // if it's not sRGB. + if icc.is_srgb() { + Self::DeviceRgb + } else { + Self::ICCBased(icc) + } + })) }) }; - return match icc_stream_object(&color_array, &icc_stream) { + let profile = match icc_stream_object(&color_array, &icc_stream) { Some(object) => { cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read) } None => read(), }; + return profile? + .or_else(|| { + dict.get::>(ALTERNATE) + .and_then(|o| Self::new(o, cache, depth + 1)) + }) + .or_else(|| match dict.get::(N) { + Some(1) => Some(Self::DeviceGray), + Some(3) => Some(Self::DeviceRgb), + Some(4) => Some(Self::DeviceCmyk), + _ => None, + }); } CALCMYK => return Some(Self::DeviceCmyk), CALGRAY => { @@ -246,19 +258,20 @@ impl ColorSpaceType { return Some(Self::Lab(Lab::new(&lab_dict)?)); } INDEXED | I => { - return Some(Self::Indexed(Indexed::new(&color_array, cache)?)); + return Some(Self::Indexed(Indexed::new(&color_array, cache, depth)?)); } SEPARATION => { - return Some(Self::Separation(Separation::new(&color_array, cache)?)); + return Some(Self::Separation(Separation::new(&color_array, cache, depth)?)); } DEVICE_N => { - return Some(Self::DeviceN(DeviceN::new(&color_array, cache)?)); + return Some(Self::DeviceN(DeviceN::new(&color_array, cache, depth)?)); } PATTERN => { - let _ = iter.next::>(); + // PdfCraft patch (72): the family is already read. Reading a name here too took + // the underlying space when it was a name or a reference. let cs = iter .next::>() - .and_then(|o| ColorSpace::new(o, cache)) + .and_then(|o| ColorSpace::within(o, cache, depth + 1)) .unwrap_or(ColorSpace::device_rgb()); return Some(Self::Pattern(cs)); } @@ -291,7 +304,12 @@ pub struct ColorSpace(Arc); impl ColorSpace { /// Create a new color space from the given object. pub(crate) fn new(object: Object<'_>, cache: &Cache) -> Option { - Some(Self(Arc::new(ColorSpaceType::new(object, cache)?))) + Self::within(object, cache, 0) + } + + /// A colour space named by another, `depth` levels down. + fn within(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + Some(Self(Arc::new(ColorSpaceType::new(object, cache, depth)?))) } /// Create a new color space from the name. @@ -991,21 +1009,22 @@ pub(crate) struct Indexed { } impl Indexed { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip name let _ = iter.next::>()?; - let base_color_space = ColorSpace::new(iter.next::>()?, cache)?; + let base_color_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; let hival = iter.next::()?.min(u8::MAX as u32) as u8; let values = { - let data = iter - .next::>() - .and_then(|s| s.decoded().ok()) - .or_else(|| { - iter.next::>() - .map(|s| Cow::Owned(s.to_vec())) - })?; + // PdfCraft patch (71): read once, as whichever it is. A failed read as a stream leaves + // a direct string in place but uses up a reference, so a table in an indirect string + // was lost, and the space with it. + let data = match iter.next::>()? { + Object::Stream(s) => s.decoded().ok()?, + Object::String(s) => Cow::Owned(s.to_vec()), + _ => return None, + }; let num_components = base_color_space.num_components(); @@ -1080,12 +1099,12 @@ fn eval_tint_transform( } impl Separation { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/Separation` let _ = iter.next::>()?; let name = iter.next::>()?; - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); @@ -1147,7 +1166,7 @@ pub(crate) struct DeviceN { } impl DeviceN { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/DeviceN` let _ = iter.next::>()?; @@ -1158,7 +1177,7 @@ impl DeviceN { .collect::>(); let num_components = u8::try_from(names.len()).ok()?; let all_none = names.iter().all(|n| n.as_str() == "None"); - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 44e076b4b1..43b558b050 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -698,3 +698,75 @@ mod tests { assert_eq!(map.len(), 12); } } + +#[cfg(test)] +mod width_tests { + use super::*; + use hayro_syntax::Pdf; + use hayro_syntax::object::ObjectIdentifier; + + /// A PDF whose object 5 is `array`, for what references in it can reach. + fn with_array(array: &str) -> Pdf { + let pdf = 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 10 10] >> endobj\n\ + 4 0 obj 20 endobj\n5 0 obj {array} endobj\n6 0 obj 22 endobj\n\ + 7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + 10 0 obj 4 0 R endobj\n11 0 obj [500 600] endobj\n12 0 obj [-1000 500 880] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n" + ); + Pdf::new(pdf.into_bytes()).unwrap() + } + + /// `/W` and `/W2` read groups of items, and either kind can start with a reference: taken for + /// the whole group it was lost, with all that followed, and those glyphs fell back to `/DW`. + #[test] + fn a_width_group_can_start_with_a_reference() { + let widths = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 [500 600]]"), by_list); + assert_eq!(widths("[4 0 R [500 600]]"), by_list); + let by_range = vec![(20, 700.0), (21, 700.0), (22, 700.0)]; + assert_eq!(widths("[20 22 700]"), by_range); + assert_eq!(widths("[4 0 R 22 700]"), by_range); + assert_eq!(widths("[4 0 R 6 0 R 700]"), by_range); + assert_eq!(widths("[1 [100] 4 0 R [500 600]]"), vec![(1, 100.0), (20, 500.0), (21, 600.0)], "after a group"); + + let vertical = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths2(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, [-1000.0, 500.0, 880.0])]; + assert_eq!(vertical("[20 [-1000 500 880]]"), by_list); + assert_eq!(vertical("[4 0 R [-1000 500 880]]"), by_list); + let by_range = vec![(20, [-900.0, 400.0, 800.0]), (21, [-900.0, 400.0, 800.0]), (22, [-900.0, 400.0, 800.0])]; + assert_eq!(vertical("[20 22 -900 400 800]"), by_range); + assert_eq!(vertical("[4 0 R 22 -900 400 800]"), by_range); + + // A group's list can be given by reference too, and a reference to a reference is what it + // names. + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 11 0 R]"), by_list); + assert_eq!(widths("[10 0 R [500 600]]"), by_list); + assert_eq!(widths("[10 0 R 11 0 R]"), by_list); + assert_eq!(vertical("[20 12 0 R]"), vec![(20, [-1000.0, 500.0, 880.0])]); + + // A reference that only leads back to itself holds no number, so those glyphs keep `/DW`. + for array in ["[7 0 R [500 600]]", "[8 0 R [500 600]]", "[20 7 0 R 700]", "[7 0 R 8 0 R 700]", "[20 [7 0 R 600]]"] { + assert!(widths(array).is_empty(), "{array}"); + } + for array in ["[7 0 R [-1000 500 880]]", "[8 0 R 22 -900 400 800]", "[20 [7 0 R 500 880]]"] { + assert!(vertical(array).is_empty(), "{array}"); + } + } +} diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 35ed975615..87738a849d 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -19,6 +19,41 @@ pub(crate) const MAX_DIMENSION: u32 = u16::MAX as u32; /// pixels, decoded one by one. pub(crate) const MAX_PIXELS: u64 = 1 << 28; +/// PdfCraft patch: the most cells a halftone grid may have, which still gives the largest page +/// (see [`MAX_PIXELS`]) cells of 3 × 3, unrotated. Each cell takes a 4-byte value while the grid decodes, so +/// the pixel bound alone let a 98-byte segment take 1 GiB; this one leaves 128 MiB. +pub(crate) const MAX_GRID_CELLS: u64 = 1 << 25; + +/// PdfCraft patch: the decoding work a stream may ask for, in pixels decoded from their code: four +/// of the largest pages, about 6 s on data the decoder predicts and about 17 s on data it can't, so +/// a render finishes inside its 20 s watchdog. Each bitmap is bounded on its own, but nothing +/// bounded how many there are: 1,140 bytes of generic regions took over 40 s, a 795-byte PDF's one +/// halftone 265 s, and a 923-byte PDF's regions would have taken days. +pub(crate) const MAX_WORK: u64 = 4 * MAX_PIXELS; + +/// PdfCraft patch: the work of reading one integer of an item, what an arithmetic-coded one takes +/// at worst in decisions (see [`MAX_WORK`]), so that a decision costs no more than a pixel. An item +/// with no pixels still reads its integers: charged a step each, a 22,515-byte PDF of refined text +/// instances of no size stayed inside `MAX_WORK` and took 730 s. +pub(crate) const INTEGER_WORK: u64 = 38; + +/// PdfCraft patch: the work of a line of a Huffman table built for symbols, about 50 ns (see +/// [`MAX_WORK`]). +pub(crate) const HUFFMAN_LINE_WORK: u64 = 10; + +/// PdfCraft patch: the work of decoding a bitmap pixel by pixel (see [`MAX_WORK`]). A row costs up +/// to 21 ns however few pixels it has, about 4 pixels' worth, so one of no width isn't free: 16,384 +/// regions of 0 × 65535 took 9 s at a cost of nothing. +pub(crate) fn decode_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width) + 4) +} + +/// PdfCraft patch: the work of copying a bitmap (see [`MAX_WORK`]): a word at a time, and a row is +/// a copy of its own, so a tall thin bitmap costs its rows, not its pixels. +pub(crate) fn copy_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width.div_ceil(WORD_BITS)) + 1) +} + /// The underlying word type for packed pixel storage. /// Change this to `u32` to switch back to 32-bit words. pub(crate) type Word = u32; @@ -26,6 +61,23 @@ pub(crate) type Word = u32; pub(crate) const WORD_BITS: u32 = Word::BITS; pub(crate) const WORD_SHIFT: u32 = WORD_BITS - 1; +/// PdfCraft patch: the words a [`Bitmap`] takes before its pixels. A symbol of width 0 has no +/// pixels and still costs this: 5 KB of zero-width dictionaries held 781 MB. +pub(crate) const BITMAP_WORDS: usize = size_of::() / size_of::(); + +/// PdfCraft patch: refuse a page past the bounds on a bitmap (see `MAX_PIXELS`). A stream is held +/// to this when it is opened as well as when its page bitmap is sized: a caller sizes its own +/// output from the page header in between, and a 743-byte PDF's Indexed image asked for 137 GB. +pub(crate) fn check_page_size(width: u32, height: u32) -> Result<()> { + if width > MAX_DIMENSION || height > MAX_DIMENSION { + return Err(OverflowError::BitmapDimension.into()); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(OverflowError::BitmapDimension.into()); + } + Ok(()) +} + /// A decoded bitmap with position information. /// /// "Pixels decoded by the MMR decoder having the value 'black' shall be treated @@ -51,6 +103,11 @@ pub(crate) struct Bitmap { } impl Bitmap { + /// PdfCraft patch: the words this bitmap holds, its header included. + pub(crate) fn words(&self) -> usize { + self.data.len().saturating_add(BITMAP_WORDS) + } + /// Create a new bitmap filled with white pixels (false). /// /// The bitmap is positioned at (0, 0). @@ -93,13 +150,7 @@ impl Bitmap { height: u32, default_pixel: bool, ) -> Result<()> { - if width > MAX_DIMENSION || height > MAX_DIMENSION { - return Err(OverflowError::BitmapDimension.into()); - } - // PdfCraft patch: see `MAX_PIXELS` (the page bitmap is sized here). - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(OverflowError::BitmapDimension.into()); - } + check_page_size(width, height)?; let stride = width.div_ceil(WORD_BITS); let default_word: Word = if default_pixel { !0 } else { 0 }; @@ -149,7 +200,8 @@ impl Bitmap { // TODO: Figure out how we can optimize this, especially since this is a // bottleneck for symbols/patterns. let dest_x_start = x.max(0); - let dest_x_end = (x + other.width as i32).min(self.width as i32); + // Saturating: a location can be anywhere an `i32` reaches, and past the end it is off the page. + let dest_x_end = x.saturating_add(other.width as i32).min(self.width as i32); if dest_x_start >= dest_x_end { return; @@ -160,7 +212,7 @@ impl Bitmap { let dest_x_end = dest_x_end as u32; for src_y in 0..other.height { - let dest_y = y + src_y as i32; + let dest_y = y.saturating_add(src_y as i32); if dest_y < 0 || dest_y >= self.height as i32 { continue; } diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index a566b8de58..a6634439d4 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -179,10 +179,12 @@ impl<'a> RefinementContextGatherer<'a> { pub(crate) fn start_row(&mut self, region: &Bitmap, reference: &Bitmap, y: u32) { self.reg_y = y; - let ref_y = y as i32 - self.reference_dy; + // The offsets can be anywhere an `i32` reaches, so coordinates in the reference wrap here and + // below. Wrapped, they are off the reference, whose pixels there read as 0. + let ref_y = (y as i32).wrapping_sub(self.reference_dy); // This will on purpose wrap to a very large `u32` for negative values, such that // they resolve to 0 in `ContextGatherer::load_word`. - let ref_x0 = (-self.reference_dx) as u32; + let ref_x0 = self.reference_dx.wrapping_neg() as u32; self.reg_cur_x = 0; self.reg_m1 = if y >= 1 { @@ -193,9 +195,9 @@ impl<'a> RefinementContextGatherer<'a> { self.reg_cur = 0; self.ref_cur_x = 0; - self.ref_m1 = ContextGatherer::load_word(reference, (ref_y - 1) as u32, 0); + self.ref_m1 = ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, 0); self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, 0); - self.ref_p1 = ContextGatherer::load_word(reference, (ref_y + 1) as u32, 0); + self.ref_p1 = ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, 0); self.ctx = match self.template { RefinementTemplate::Template1 => { @@ -257,7 +259,7 @@ impl<'a> RefinementContextGatherer<'a> { self.reg_cur = ContextGatherer::load_word(region, self.reg_y, new_start); } - let ref_x_signed = x as i32 - self.reference_dx; + let ref_x_signed = (x as i32).wrapping_sub(self.reference_dx); if ref_x_signed < 0 { self.ref_cur_x = 0; self.ref_m1 = 0; @@ -268,18 +270,20 @@ impl<'a> RefinementContextGatherer<'a> { if ref_x + 1 >= self.ref_cur_x + WORD_BITS || ref_x < self.ref_cur_x { let new_start = ref_x.saturating_sub(1); - let ref_y = self.reg_y as i32 - self.reference_dy; + let ref_y = (self.reg_y as i32).wrapping_sub(self.reference_dy); self.ref_cur_x = new_start; - self.ref_m1 = ContextGatherer::load_word(reference, (ref_y - 1) as u32, new_start); + self.ref_m1 = + ContextGatherer::load_word(reference, ref_y.wrapping_sub(1) as u32, new_start); self.ref_cur = ContextGatherer::load_word(reference, ref_y as u32, new_start); - self.ref_p1 = ContextGatherer::load_word(reference, (ref_y + 1) as u32, new_start); + self.ref_p1 = + ContextGatherer::load_word(reference, ref_y.wrapping_add(1) as u32, new_start); } } } #[inline(always)] pub(crate) fn tpgr_all_same(&self, x: u32) -> bool { - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); #[inline(always)] @@ -310,7 +314,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] pub(crate) fn ref_center_pixel(&self, x: u32) -> u8 { - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); ContextGatherer::get_buf_pixel(self.ref_cur, rbx) as u8 @@ -329,7 +333,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template0_default(&mut self, _region: &Bitmap, _reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); let new_pixels = (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 10) @@ -345,12 +349,12 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template0_custom(&mut self, region: &Bitmap, reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x_i = x as i32 - self.reference_dx; + let ref_x_i = (x as i32).wrapping_sub(self.reference_dx); let rbx = ref_x_i as u32 - self.ref_cur_x; let xi = x as i32; let yi = self.reg_y as i32; - let ref_y = yi - self.reference_dy; + let ref_y = yi.wrapping_sub(self.reference_dy); let at1 = self.at_pixels[0]; let at2 = self.at_pixels[1]; @@ -360,8 +364,8 @@ impl<'a> RefinementContextGatherer<'a> { | (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 10) | (ContextGatherer::get_buf_pixel(self.reg_cur, bx.wrapping_sub(1)) << 9) | ((reference.get_pixel( - (ref_x_i + at2.x as i32) as u32, - (ref_y + at2.y as i32) as u32, + ref_x_i.wrapping_add(at2.x as i32) as u32, + ref_y.wrapping_add(at2.y as i32) as u32, ) as u16) << 8) | (ContextGatherer::get_buf_pixel(self.ref_m1, rbx.wrapping_add(1)) << 6) @@ -375,7 +379,7 @@ impl<'a> RefinementContextGatherer<'a> { #[inline(always)] fn gather_template1(&mut self, _region: &Bitmap, _reference: &Bitmap, x: u32) -> u16 { let bx = x - self.reg_cur_x; - let ref_x = (x as i32 - self.reference_dx) as u32; + let ref_x = (x as i32).wrapping_sub(self.reference_dx) as u32; let rbx = ref_x.wrapping_sub(self.ref_cur_x); let new_pixels = (ContextGatherer::get_buf_pixel(self.reg_m1, bx + 1) << 7) diff --git a/vendor/hayro-jbig2/src/decode/halftone.rs b/vendor/hayro-jbig2/src/decode/halftone.rs index eb5c1c21e8..10000f801b 100644 --- a/vendor/hayro-jbig2/src/decode/halftone.rs +++ b/vendor/hayro-jbig2/src/decode/halftone.rs @@ -7,7 +7,7 @@ use super::RegionBitmap; use super::pattern::PatternDictionary; use super::{CombinationOperator, RegionSegmentInfo, Template, parse_region_segment_info}; use crate::ScratchBuffers; -use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS}; +use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS, copy_work, decode_work}; use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; use crate::gray_scale::{GrayScaleParams, decode_gray_scale_image}; use crate::reader::Reader; @@ -48,11 +48,7 @@ pub(crate) fn decode_into( None }; - // "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) - let bits_per_pixel = (pattern_dict.patterns.len() as u32) - .saturating_sub(1) - .checked_ilog2() - .map_or(1, |n| n + 1); + let bits_per_pixel = bits_per_pixel(pattern_dict); // "4) Decode an image GI of size HGW by HGH with HBPP bits per pixel using // the gray-scale image decoding procedure as described in Annex C." (6.6.5) @@ -73,6 +69,28 @@ pub(crate) fn decode_into( Ok(()) } +/// PdfCraft patch: the work of a halftone region (see `bitmap::MAX_WORK`): a bit plane of the grid +/// for each bit of a pattern's index, a pattern copied for each cell, and the region itself. One +/// such segment in a 795-byte PDF took 265 s. +pub(crate) fn work(header: &HalftoneRegionHeader<'_>, patterns: &PatternDictionary) -> u64 { + let grid = &header.grid_position_and_size; + let cells = u64::from(grid.width) * u64::from(grid.height); + let planes = u64::from(bits_per_pixel(patterns)); + let copied = copy_work(patterns.pattern_width, patterns.pattern_height); + planes + .saturating_mul(decode_work(grid.width, grid.height)) + .saturating_add(cells.saturating_mul(copied)) + .saturating_add(copy_work(header.region_info.width, header.region_info.height)) +} + +/// "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) +fn bits_per_pixel(patterns: &PatternDictionary) -> u32 { + (patterns.patterns.len() as u32) + .saturating_sub(1) + .checked_ilog2() + .map_or(1, |n| n + 1) +} + /// Parse a halftone region segment header (7.4.5.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let region_info = parse_region_segment_info(reader)?; @@ -97,8 +115,8 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result MAX_DIMENSION || grid_height > MAX_DIMENSION { bail!(OverflowError::GridDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_PIXELS { + // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). + if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_GRID_CELLS { bail!(OverflowError::GridDimension); } diff --git a/vendor/hayro-jbig2/src/decode/pattern.rs b/vendor/hayro-jbig2/src/decode/pattern.rs index 2c15d70b8f..03d4d8eb89 100644 --- a/vendor/hayro-jbig2/src/decode/pattern.rs +++ b/vendor/hayro-jbig2/src/decode/pattern.rs @@ -3,15 +3,20 @@ use super::{AdaptiveTemplatePixel, Template, generic}; use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS, Word}; +use crate::bitmap::{Bitmap, WORD_BITS, Word, copy_work}; use crate::error::{OverflowError, ParseError, Result}; use crate::reader::Reader; use alloc::vec::Vec; +/// PdfCraft patch: the most words of shifted copies a dictionary keeps, 16 MiB, where 256 patterns +/// of 32 rows take 2 MiB. A 48-byte dictionary of 65,535 such patterns asked for 539 MB. +const MAX_SHIFTED_WORDS: u64 = 1 << 22; + /// Decode a pattern dictionary segment (7.4.4.2, 6.7). pub(crate) fn decode( header: &PatternDictionaryHeader<'_>, scratch: &mut ScratchBuffers, + room: u64, ) -> Result { let pattern_width = header.pattern_width as u32; let pattern_height = header.pattern_height as u32; @@ -133,7 +138,19 @@ pub(crate) fn decode( // for all possible 32-bit alignments, which later on allows us to // very easily blit it into the backdrop, significantly speeding up // the whole process. - let shifted_patterns = if pattern_width <= WORD_BITS && pattern_height <= WORD_BITS { + // + // PdfCraft patch: only when the table is small, and fits what the page may still hold + // (`room`), so that a speed-up never gets an image refused. It can be left out: + // `shifted_pattern` then returns `None` and the patterns are combined the slow way. + let shifted_words = (patterns.len() as u64) + .saturating_mul(32 * 2) + .saturating_mul(u64::from(pattern_height)); + let pattern_words = patterns.iter().map(|p| p.words() as u64).fold(0, u64::saturating_add); + let shifted_patterns = if pattern_width <= WORD_BITS + && pattern_height <= WORD_BITS + && shifted_words <= MAX_SHIFTED_WORDS + && shifted_words.saturating_add(pattern_words) <= room + { let h = pattern_height as usize; let mut data = Vec::with_capacity(patterns.len() * 32 * h * 2); for pattern in &patterns { @@ -180,6 +197,11 @@ impl PatternDictionary { let start = (pattern_idx * 32 + bit_offset as usize) * len; self.shifted_patterns.get(start..start + len) } + + /// PdfCraft patch: the words the dictionary holds, its patterns and their shifted copies. + pub(crate) fn words(&self) -> usize { + self.patterns.iter().map(Bitmap::words).fold(self.shifted_patterns.len(), usize::saturating_add) + } } /// Parsed pattern dictionary segment header (7.4.4.1). @@ -196,6 +218,17 @@ pub(crate) struct PatternDictionaryHeader<'a> { pub(crate) data: &'a [u8], } +impl PatternDictionaryHeader<'_> { + /// PdfCraft patch: the work of decoding the patterns, side by side in one bitmap, and of + /// taking each out of it (see `bitmap::MAX_WORK`). + pub(crate) fn work(&self) -> u64 { + let (width, height) = (u32::from(self.pattern_width), u32::from(self.pattern_height)); + let patterns = u64::from(self.num_patterns) + 1; + let side_by_side = u64::from(height) * (patterns.saturating_mul(u64::from(width)) + 4); + side_by_side.saturating_add(patterns.saturating_mul(copy_work(width, height))) + } +} + /// Parse a pattern dictionary segment header (7.4.4.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let flags_byte = reader.read_byte().ok_or(ParseError::UnexpectedEof)?; diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index f1aa8a6639..b682d4571c 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -4,7 +4,7 @@ use alloc::vec; use alloc::vec::Vec; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS}; +use crate::bitmap::{BITMAP_WORDS, Bitmap, HUFFMAN_LINE_WORK, INTEGER_WORK, WORD_BITS, Word, decode_work}; use crate::decode::generic::{decode_bitmap_mmr, parse_adaptive_template_pixels}; use crate::decode::text::{ DecodeContext, ReferenceCorner, TextRegionContexts, TextRegionFlags, TextRegionHeader, @@ -45,6 +45,8 @@ pub(crate) fn decode( referred_tables: &[HuffmanTable], standard_tables: &StandardHuffmanTables, input_contexts: Option<&RetainedContexts>, + room: u64, + work_left: &mut u64, ) -> Result { let num_new_symbols = header.num_new_symbols; @@ -58,6 +60,9 @@ pub(crate) fn decode( total_width: 0, height_class_height: 0, pixels: 0, + new_words: 0, + room, + work_left, header, standard_tables, }; @@ -96,6 +101,8 @@ pub(crate) fn decode( bail!(SymbolError::Invalid); } height_classes_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A class of no symbols still reads its delta. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let height_class_delta = read_height_class_delta(&mut ctx)?.ok_or(SymbolError::UnexpectedOob)?; @@ -130,6 +137,19 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } + // PdfCraft patch: see `bitmap::INTEGER_WORK`. Its width, and a stream can hold thousands + // of dictionaries of symbols of no pixels. An aggregated one reads its count, a symbol + // and two offsets as well. + let integers = if ctx.header.flags.use_refagg { 5 } else { 1 }; + let work = decode_work(symbol_width, ctx.height_class_height).saturating_add(integers * INTEGER_WORK); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column + // held 270 MiB within the pixel bound, however few the dictionary exported. + let rows = u64::from(symbol_width.div_ceil(WORD_BITS)) * u64::from(ctx.height_class_height); + ctx.new_words = ctx.new_words.saturating_add(rows).saturating_add(BITMAP_WORDS as u64); + if ctx.new_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } match (ctx.header.flags.use_huffman, ctx.header.flags.use_refagg) { (false, false) => { @@ -202,6 +222,18 @@ pub(crate) struct SymbolDictionary { pub(crate) retained_contexts: Option, } +impl SymbolDictionary { + /// PdfCraft patch: the words the dictionary holds, its retained contexts included. Those are + /// a byte each, and 1.1 MB of dictionaries that retain them, 103 KB behind Flate, held 2.89 GB. + pub(crate) fn words(&self) -> usize { + let contexts = self.retained_contexts.as_ref().map_or(0, |contexts| { + let bytes = contexts.generic_region.len().saturating_add(contexts.refinement_region.len()); + bytes.saturating_mul(size_of::()) / size_of::() + }); + self.exported_symbols.iter().map(Bitmap::words).fold(contexts, usize::saturating_add) + } +} + /// Decode a symbol bitmap using refinement/aggregate coding (6.5.8.2). fn decode_refinement_aggregation_bitmap( ctx: &mut SymbolDecodeContext<'_>, @@ -337,6 +369,11 @@ fn decode_aggregation_bitmap( ) -> Result { let use_huffman = ctx.header.flags.use_huffman; + // PdfCraft patch: see `bitmap::MAX_WORK`. What follows is sized by all the symbols, for each + // aggregated symbol, at about 50 ns a symbol: a 713-byte PDF asked for an hour. + let work = u64::from(ctx.total_symbols()).saturating_mul(HUFFMAN_LINE_WORK); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + // Concatenate input and new symbols. let mut all_symbols: Vec<&Bitmap> = Vec::with_capacity(ctx.symbols.input.len() + ctx.symbols.new.len()); @@ -422,7 +459,7 @@ fn decode_aggregation_bitmap( header.region_info.y_location, header.flags.default_pixel, )?; - decode_with(decode_ctx, &all_symbols, &header, &mut bitmap)?; + decode_with(decode_ctx, &all_symbols, &header, &mut bitmap, ctx.work_left)?; Ok(bitmap) } @@ -463,6 +500,14 @@ struct SymbolDecodeContext<'a> { symbols_decoded_count: u32, total_width: u32, height_class_height: u32, + /// PdfCraft patch: the words the page may still hold, which neither the new symbols nor the + /// exported ones may pass. They are counted as they are made: counted only once the + /// dictionary was stored, 8 MiB symbols exported again and again killed the process. + room: u64, + /// PdfCraft patch: the words of the new symbols so far (see `room`). + new_words: u64, + /// PdfCraft patch: the decoding work the stream may still ask for (see `bitmap::MAX_WORK`). + work_left: &'a mut u64, /// PdfCraft patch: symbol pixels decoded so far (see `MAX_DICTIONARY_PIXELS`). pixels: u64, } @@ -612,6 +657,17 @@ fn decode_height_class_collective_bitmap(ctx: &mut SymbolDecodeContext<'_>) -> R ctx.h_ctx.reader.align(); + // PdfCraft patch: a class of no symbols has nothing to cut out, and `bitmap::decode_work` charges + // rows by the symbol, so decoding this would be rows for nothing: 65,535 a class, and a dictionary + // can hold a thousand such classes. Its bytes still have to be stepped over. + if ctx.symbols_decoded_count == ctx.height_class_first_symbol { + ctx.h_ctx + .reader + .read_bytes(bitmap_size as usize) + .ok_or(ParseError::UnexpectedEof)?; + return Ok(()); + } + let collective_bitmap = if bitmap_size == 0 { // Bitmap is stored uncompressed, so we basically just copy-paste // each byte. Rows are padded to the byte boundary. @@ -708,6 +764,7 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { u32::try_from(value).map_err(|_| HuffmanError::InvalidCode.into()) }; let mut exported = Vec::with_capacity(ctx.header.num_exported_symbols as usize); + let mut exported_words: u64 = 0; let mut index: u32 = 0; let mut should_export = false; // PdfCraft patch: see `EXTRA_EMPTY_STEPS`. @@ -718,6 +775,8 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { bail!(SymbolError::Invalid); } runs_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A run of no symbols still reads its length. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let run_length = read_run_length()?; let end_index = index.checked_add(run_length).ok_or(OverflowError::Index)?; @@ -730,9 +789,13 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { let symbol = ctx .symbols .get(symbol_idx as usize) - .ok_or(SymbolError::OutOfRange)? - .clone(); - exported.push(symbol); + .ok_or(SymbolError::OutOfRange)?; + // PdfCraft patch: see `room`. + exported_words = exported_words.saturating_add(symbol.words() as u64); + if exported_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } + exported.push(symbol.clone()); } } @@ -864,3 +927,25 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result Result { let mut bitmap = Bitmap::new_with( header.region_info.width, @@ -41,6 +42,7 @@ pub(crate) fn decode( standard_tables, &mut bitmap, scratch, + work_left, )?; Ok(RegionBitmap { @@ -56,12 +58,13 @@ pub(crate) fn decode_into( standard_tables: &StandardHuffmanTables, bitmap: &mut Bitmap, scratch: &mut ScratchBuffers, + work_left: &mut u64, ) -> Result<()> { if header.flags.use_huffman { let mut reader = Reader::new(header.data); let ctx = DecodeContext::new_huffman(&mut reader, header, referred_tables, standard_tables)?; - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } else { let mut decoder = ArithmeticDecoder::new(header.data); @@ -76,7 +79,7 @@ pub(crate) fn decode_into( .resize(num_gr_contexts, ArithmeticDecoderContext::default()); let ctx = DecodeContext::new_arithmetic(&mut decoder, &mut contexts, &mut scratch.contexts); - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } Ok(()) @@ -88,6 +91,7 @@ pub(crate) fn decode_with( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, region: &mut Bitmap, + work_left: &mut u64, ) -> Result<()> { let strip_size = header.strip_size(); @@ -147,7 +151,7 @@ pub(crate) fn decode_with( let symbol_id = ctx.read_symbol_id()?; let symbol_bitmap = - decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id)?; + decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id, work_left)?; let symbol_bitmap_ref: &Bitmap = match &symbol_bitmap { SymbolBitmap::Reference(idx) => symbols.get(*idx).ok_or(SymbolError::OutOfRange)?, @@ -156,6 +160,11 @@ pub(crate) fn decode_with( let symbol_width = symbol_bitmap_ref.width as i32; let symbol_height = symbol_bitmap_ref.height as i32; + // PdfCraft patch: see `bitmap::MAX_WORK`. An instance of a large symbol costs its copy, + // and 10,000 of one took 64 s. Even one with no pixels reads its four integers. + let work = copy_work(symbol_bitmap_ref.width, symbol_bitmap_ref.height).saturating_add(4 * INTEGER_WORK); + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + if !header.flags.transposed && (header.flags.reference_corner == ReferenceCorner::TopRight || header.flags.reference_corner == ReferenceCorner::BottomRight) @@ -173,23 +182,26 @@ pub(crate) fn decode_with( } let symbol_s = current_s; + // From the corner a symbol is placed by to its far side. The strip and the offset can + // be anywhere an `i32` reaches, and wrapped, the symbol is off the region. + let back = |corner: i32, size: i32| corner.wrapping_sub(size).wrapping_add(1); let (x, y) = if !header.flags.transposed { match header.flags.reference_corner { ReferenceCorner::TopLeft => (symbol_s, symbol_t), - ReferenceCorner::TopRight => (symbol_s - symbol_width + 1, symbol_t), - ReferenceCorner::BottomLeft => (symbol_s, symbol_t - symbol_height + 1), + ReferenceCorner::TopRight => (back(symbol_s, symbol_width), symbol_t), + ReferenceCorner::BottomLeft => (symbol_s, back(symbol_t, symbol_height)), ReferenceCorner::BottomRight => { - (symbol_s - symbol_width + 1, symbol_t - symbol_height + 1) + (back(symbol_s, symbol_width), back(symbol_t, symbol_height)) } } } else { match header.flags.reference_corner { ReferenceCorner::TopLeft => (symbol_t, symbol_s), - ReferenceCorner::TopRight => (symbol_t - symbol_width + 1, symbol_s), - ReferenceCorner::BottomLeft => (symbol_t, symbol_s - symbol_height + 1), + ReferenceCorner::TopRight => (back(symbol_t, symbol_width), symbol_s), + ReferenceCorner::BottomLeft => (symbol_t, back(symbol_s, symbol_height)), ReferenceCorner::BottomRight => { - (symbol_t - symbol_width + 1, symbol_s - symbol_height + 1) + (back(symbol_t, symbol_width), back(symbol_s, symbol_height)) } } }; @@ -531,6 +543,7 @@ fn decode_symbol_instance_bitmap( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, symbol_id: usize, + work_left: &mut u64, ) -> Result { if !header.flags.use_refinement || ctx.read_refinement_flag()? == 0 { return Ok(SymbolBitmap::Reference(symbol_id)); @@ -562,6 +575,11 @@ fn decode_symbol_instance_bitmap( .checked_add(rdy) .ok_or(OverflowError::ReferenceOffset)?; + // PdfCraft patch: see `bitmap::MAX_WORK`. Before it is decoded: charged after, one instance + // could still be 2^28 pixels past what was left. With the four integers that gave its size. + let work = decode_work(refined_width, refined_height).saturating_add(4 * INTEGER_WORK); + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + let mut refined_bitmap = Bitmap::new(refined_width, refined_height)?; ctx.decode_refinement_bitmap( diff --git a/vendor/hayro-jbig2/src/error.rs b/vendor/hayro-jbig2/src/error.rs index c579368b44..c8bcf8b811 100644 --- a/vendor/hayro-jbig2/src/error.rs +++ b/vendor/hayro-jbig2/src/error.rs @@ -133,8 +133,14 @@ pub enum OverflowError { ReferenceOffset, /// Index computation overflowed. Index, - /// A symbol dictionary has too many symbols. + /// A symbol dictionary has too many symbols, or a segment refers to too many. SymbolCount, + /// PdfCraft patch: the segments retain more bitmaps than a page may hold. + RetainedBitmaps, + /// PdfCraft patch: a stream has more segments, or references, than it may. + SegmentCount, + /// PdfCraft patch: a stream asks for more decoding work than it may. + DecodingWork, } impl fmt::Display for DecodeError { @@ -161,7 +167,10 @@ impl fmt::Display for OverflowError { Self::ReferenceOffset => write!(f, "overflow in reference offset computation"), Self::Index => write!(f, "overflow in index computation"), Self::GridDimension => write!(f, "overflow in grid dimension computation"), - Self::SymbolCount => write!(f, "a symbol dictionary has too many symbols"), + Self::SymbolCount => write!(f, "too many symbols in a dictionary or referred to"), + Self::RetainedBitmaps => write!(f, "the segments retain too many bitmaps"), + Self::SegmentCount => write!(f, "too many segments or references in a stream"), + Self::DecodingWork => write!(f, "a stream asks for too much decoding work"), } } } diff --git a/vendor/hayro-jbig2/src/file.rs b/vendor/hayro-jbig2/src/file.rs index 1440a22efa..5b8bbe983b 100644 --- a/vendor/hayro-jbig2/src/file.rs +++ b/vendor/hayro-jbig2/src/file.rs @@ -5,12 +5,39 @@ use alloc::vec::Vec; -use crate::error::{FormatError, ParseError, Result, bail}; +use crate::error::{FormatError, OverflowError, ParseError, Result, bail}; use crate::reader::Reader; use crate::segment::{ Segment, SegmentType, parse_segment, parse_segment_data, parse_segment_header, }; +/// PdfCraft patch: the most segments a stream, with its globals, may have. They are all parsed and +/// sorted before the first is decoded, at about 64 bytes for 11 of input, so behind nested Flate a +/// 4,765-byte PDF reached 5 GB and failed only afterwards. +const MAX_SEGMENTS: usize = 1 << 20; + +/// PdfCraft patch: the most references those segments may make, 4 bytes each for 1 of input. +const MAX_REFERENCES: usize = 1 << 22; + +/// PdfCraft patch: the segments of a stream so far (see [`MAX_SEGMENTS`], [`MAX_REFERENCES`]). +#[derive(Default)] +pub(crate) struct SegmentCount { + segments: usize, + references: usize, +} + +impl SegmentCount { + /// Count one more segment, making `references` references. + fn add(&mut self, references: usize) -> Result<()> { + self.segments = self.segments.saturating_add(1); + self.references = self.references.saturating_add(references); + if self.segments > MAX_SEGMENTS || self.references > MAX_REFERENCES { + bail!(OverflowError::SegmentCount); + } + Ok(()) + } +} + /// "There are two standalone file organizations possible for a JBIG2 bitstream." /// (Annex D) #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -140,7 +167,9 @@ fn parse_segments<'a>( let mut segments = Vec::new(); match organization { - FileOrganization::Sequential => parse_segments_sequential(reader, &mut segments)?, + FileOrganization::Sequential => { + parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?; + } FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?, } @@ -155,6 +184,7 @@ fn parse_segments<'a>( pub(crate) fn parse_segments_sequential<'a>( reader: &mut Reader<'a>, segments: &mut Vec>, + count: &mut SegmentCount, ) -> Result<()> { loop { if reader.at_end() { @@ -162,6 +192,7 @@ pub(crate) fn parse_segments_sequential<'a>( } let segment = parse_segment(reader)?; + count.add(segment.header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) @@ -187,6 +218,7 @@ fn parse_segments_random_access<'a>( segments: &mut Vec>, ) -> Result<()> { let mut headers = Vec::new(); + let mut count = SegmentCount::default(); loop { if reader.at_end() { @@ -194,6 +226,7 @@ fn parse_segments_random_access<'a>( } let header = parse_segment_header(reader)?; + count.add(header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) diff --git a/vendor/hayro-jbig2/src/gray_scale.rs b/vendor/hayro-jbig2/src/gray_scale.rs index 2836ec77a7..4aa4229a5b 100644 --- a/vendor/hayro-jbig2/src/gray_scale.rs +++ b/vendor/hayro-jbig2/src/gray_scale.rs @@ -83,8 +83,8 @@ fn decode_arithmetic( if width > MAX_DIMENSION || height > MAX_DIMENSION { bail!(OverflowError::BitmapDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(width) * u64::from(height) > crate::bitmap::MAX_PIXELS { + // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). + if u64::from(width) * u64::from(height) > crate::bitmap::MAX_GRID_CELLS { bail!(OverflowError::BitmapDimension); } diff --git a/vendor/hayro-jbig2/src/huffman_table.rs b/vendor/hayro-jbig2/src/huffman_table.rs index 2183c8eea0..1a20da5281 100644 --- a/vendor/hayro-jbig2/src/huffman_table.rs +++ b/vendor/hayro-jbig2/src/huffman_table.rs @@ -14,11 +14,27 @@ include!("huffman_tables_generated.rs"); /// Maximum number of nodes in an inline Huffman table. const INLINE_TABLE_SIZE: usize = 43; +/// PdfCraft patch: the most lines a custom table may have. A line takes 2 bits of input at least +/// and 8 bytes here, so a table reading 250 MiB behind nested Flate, a 1,297-byte PDF, was killed +/// at 8 GiB before it could be counted. +const MAX_TABLE_LINES: usize = 1 << 16; + /// A queryable Huffman table. #[derive(Debug, Clone)] pub(crate) struct HuffmanTable(Rc); impl HuffmanTable { + /// PdfCraft patch: the words this table holds. Every table takes the room of the inline + /// variant, however few nodes it has, and a dynamic one its nodes besides. + pub(crate) fn words(&self) -> usize { + let nodes = match &*self.0 { + InnerHuffmanTable::Inline { .. } => 0, + InnerHuffmanTable::Dynamic { nodes } => nodes.len(), + }; + let bytes = nodes.saturating_mul(size_of::()); + bytes.saturating_add(size_of::()) / size_of::() + } + /// Create a new inline Huffman table from a fixed-size node array. fn from_inline(nodes: [HuffmanNode; INLINE_TABLE_SIZE]) -> Self { Self(Rc::new(InnerHuffmanTable::Inline { nodes })) @@ -219,6 +235,9 @@ impl HuffmanTable { // 5) "Decode each table line as follows:" // d) "If CURRANGELOW ≥ HTHIGH then proceed to step 6." while current_range_low < maximum_value { + if lines.len() >= MAX_TABLE_LINES { + bail!(HuffmanError::InvalidCode); + } // a) "Read HTPS bits. Set PREFLEN[NTEMP] to the value decoded." let prefix_length = reader .read_bits(prefix_length_bits) @@ -448,11 +467,11 @@ impl HuffmanNode { // 4) "Otherwise, if table line I is the lower range table line for this // table, then set: HTVAL = RANGELOW[I] − HTOFFSET" // 5) "Otherwise, set: HTVAL = RANGELOW[I] + HTOFFSET" - // `HTVAL` + // `HTVAL`. Wrapping: a range line's offset can take it past what an `i32` holds. let value = if leaf.is_lower { - leaf.range_low - range_offset + leaf.range_low.wrapping_sub(range_offset) } else { - leaf.range_low + range_offset + leaf.range_low.wrapping_add(range_offset) }; return Ok(Some(value)); diff --git a/vendor/hayro-jbig2/src/integer_decoder.rs b/vendor/hayro-jbig2/src/integer_decoder.rs index 8b7656d296..7f9f7831a2 100644 --- a/vendor/hayro-jbig2/src/integer_decoder.rs +++ b/vendor/hayro-jbig2/src/integer_decoder.rs @@ -70,7 +70,8 @@ impl IntegerDecoder { if s == 0 { Some(v as i32) } else if v > 0 { - Some(-(v as i32)) + // Wrapping: `V` can be 2^31, whose negative is `i32::MIN`, as the cast already reads it. + Some((v as i32).wrapping_neg()) } else { None } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index ad77a6dd17..b8ab097b76 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -75,7 +75,7 @@ pub use error::{ SegmentError, SymbolError, TemplateError, }; -use crate::file::parse_segments_sequential; +use crate::file::{SegmentCount, parse_segments_sequential}; use bitmap::Bitmap; use decode::CombinationOperator; use decode::generic; @@ -120,13 +120,14 @@ impl<'a> Image<'a> { /// Annex D.3. pub fn new_embedded(data: &'a [u8], globals: Option<&'a [u8]>) -> Result { let mut segments = Vec::new(); + let mut count = SegmentCount::default(); if let Some(globals_data) = globals { let mut reader = Reader::new(globals_data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; }; let mut reader = Reader::new(data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; segments.sort_by_key(|seg| seg.header.segment_number); @@ -162,6 +163,7 @@ impl<'a> Image<'a> { if page_info.width == 0 || height == 0 { bail!(FormatError::EmptyPage); } + bitmap::check_page_size(page_info.width, height)?; Ok(Self { segments, @@ -257,6 +259,18 @@ fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { } } +/// PdfCraft patch: count `work` more decoding work, refusing past [`bitmap::MAX_WORK`] before what +/// asks for it is decoded. +fn charge_work(left: &mut u64, work: u64) -> Result<()> { + *left = left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + Ok(()) +} + +/// PdfCraft patch: the work of a region decoded pixel by pixel. +fn region_work(region: &decode::RegionSegmentInfo) -> u64 { + bitmap::decode_work(region.width, region.height) +} + fn decode_segments( segments: &[segment::Segment<'_>], height_from_stripes: Option, @@ -281,6 +295,7 @@ fn decode_segments( let page_bitmap = &mut decoder_ctx.page_bitmap; let page_state = &mut decoder_ctx.page_state; let scratch_buffers = &mut decoder_ctx.scratch_buffers; + let mut work_left = bitmap::MAX_WORK; // Process all segments. for seg in segments { @@ -293,6 +308,7 @@ fn decode_segments( SegmentType::ImmediateGenericRegion | SegmentType::ImmediateLosslessGenericRegion => { let had_unknown_length = seg.header.data_length.is_none(); let header = generic::parse(&mut reader, had_unknown_length)?; + charge_work(&mut work_left, region_work(&header.region_info))?; if page_state.can_decode_directly(page_bitmap, &header.region_info, false) { generic::decode_into(&header, page_bitmap, scratch_buffers)?; @@ -310,25 +326,22 @@ fn decode_segments( SegmentType::IntermediateGenericRegion => { // Intermediate segments cannot have unknown length. let header = generic::parse(&mut reader, false)?; + charge_work(&mut work_left, region_work(&header.region_info))?; let region = generic::decode(&header, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::PatternDictionary => { let header = pattern::parse(&mut reader)?; - let dictionary = pattern::decode(&header, scratch_buffers)?; - page_state.store_pattern_dictionary(seg.header.segment_number, dictionary); + charge_work(&mut work_left, header.work())?; + let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?; + page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::SymbolDictionary => { // "1) Concatenate all the input symbol dictionaries to form SDINSYMS." // (6.5.5, step 1) // Collect references to avoid cloning; symbols are only cloned if re-exported. - let input_symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, input_symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -354,18 +367,15 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, retained_contexts, + page_state.retained_room(), + &mut work_left, )?; - page_state.store_symbol_dictionary(seg.header.segment_number, dictionary); + page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. // "These user-supplied Huffman decoding tables may be supplied either @@ -379,6 +389,11 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } if page_state.can_decode_directly( page_bitmap, @@ -392,6 +407,7 @@ fn decode_segments( &page_state.standard_tables, page_bitmap, scratch_buffers, + &mut work_left, )?; } else { let region = text::decode( @@ -400,6 +416,7 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, scratch_buffers, + &mut work_left, )?; page_bitmap.combine( ®ion.bitmap, @@ -412,13 +429,8 @@ fn decode_segments( } SegmentType::IntermediateTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -430,14 +442,20 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } let region = text::decode( &header, &symbols, &referred_tables, &page_state.standard_tables, scratch_buffers, + &mut work_left, )?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateHalftoneRegion | SegmentType::ImmediateLosslessHalftoneRegion => { let pattern_dict = seg @@ -448,6 +466,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; if page_state.can_decode_directly( page_bitmap, @@ -475,8 +494,9 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. @@ -488,8 +508,9 @@ fn decode_segments( .unwrap_or(page_bitmap); let header = generic_refinement::parse(&mut reader)?; + charge_work(&mut work_left, region_work(&header.region_info))?; let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion | SegmentType::ImmediateLosslessGenericRefinementRegion => { @@ -505,6 +526,7 @@ fn decode_segments( .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; + charge_work(&mut work_left, region_work(&header.region_info))?; if let Some(referred_segment) = referred_segment && page_state.can_decode_directly(page_bitmap, &header.region_info, false) @@ -532,7 +554,7 @@ fn decode_segments( // "This segment contains data which defines one or more user-supplied // Huffman coding tables." (7.4.13) let table = HuffmanTable::read_custom(&mut reader)?; - page_state.store_huffman_table(seg.header.segment_number, table); + page_state.store_huffman_table(seg.header.segment_number, table)?; } SegmentType::EndOfPage | SegmentType::EndOfFile => { break; @@ -545,6 +567,23 @@ fn decode_segments( Ok(()) } +/// PdfCraft patch: the words a stored item costs in its list, at least, apart from what it holds. +const ENTRY_WORDS: u64 = 32; + +/// PdfCraft patch: the most words a page's segments may hold for later ones, 64 MiB: two square +/// bitmaps of 16,384 a side (`bitmap::MAX_PIXELS`). Each stored item is bounded on its own, but without a +/// bound on all of them a stream of a few dozen bytes a segment held 33 MB each (a 16,384-sample +/// square region of typical rows), and a few kilobytes could exhaust memory. A segment being +/// decoded takes more for a while, within its own bounds: the page bitmap isn't counted, nor is +/// what a region or a grid uses until it is stored or freed. +const MAX_RETAINED_WORDS: u64 = + 2 * (bitmap::MAX_PIXELS / bitmap::WORD_BITS as u64 + bitmap::BITMAP_WORDS as u64 + ENTRY_WORDS); + +/// PdfCraft patch: the most symbols one segment may refer to, 2^20, far beyond the 65,535 a +/// dictionary may export. References may repeat, so each names the same symbols again: without +/// this, 65,535 references to one dictionary in a 78 KB stream collected 17 GB of pointers. +const MAX_REFERRED_SYMBOLS: u64 = 1 << 20; + /// Page-level decoding state for a JBIG2 page. #[derive(Default)] pub(crate) struct PageState { @@ -561,6 +600,9 @@ pub(crate) struct PageState { /// Decoded Huffman tables from table segments, stored as (`segment_number`, table) pairs. /// "Tables – see 7.4.13." (type 53) pub(crate) huffman_tables: Vec<(u32, HuffmanTable)>, + /// PdfCraft patch: the words the regions, dictionaries and tables above hold, at most + /// [`MAX_RETAINED_WORDS`]. + pub(crate) retained_words: u64, /// Standard Huffman tables (`TABLE_A` through `TABLE_O`). pub(crate) standard_tables: StandardHuffmanTables, } @@ -573,6 +615,7 @@ impl PageState { self.pattern_dictionaries.clear(); self.symbol_dictionaries.clear(); self.huffman_tables.clear(); + self.retained_words = 0; // Standard tables are lazily built and reused across images. } @@ -610,9 +653,26 @@ impl PageState { } } + /// PdfCraft patch: the words the page may still hold for later segments. + fn retained_room(&self) -> u64 { + MAX_RETAINED_WORDS.saturating_sub(self.retained_words) + } + + /// PdfCraft patch: count a stored item holding `words` for later segments, refusing past + /// [`MAX_RETAINED_WORDS`]. + fn retain(&mut self, words: usize) -> Result<()> { + self.retained_words = self.retained_words.saturating_add(words as u64).saturating_add(ENTRY_WORDS); + if self.retained_words > MAX_RETAINED_WORDS { + bail!(OverflowError::RetainedBitmaps); + } + Ok(()) + } + /// Store a decoded region for later reference. - fn store_region(&mut self, segment_number: u32, region: Bitmap) { + fn store_region(&mut self, segment_number: u32, region: Bitmap) -> Result<()> { + self.retain(region.words())?; self.referred_segments.push((segment_number, region)); + Ok(()) } /// Look up a referred segment by number. @@ -624,8 +684,10 @@ impl PageState { } /// Store a decoded pattern dictionary for later reference. - fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) { + fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.pattern_dictionaries.push((segment_number, dictionary)); + Ok(()) } /// Look up a pattern dictionary by segment number. @@ -637,8 +699,23 @@ impl PageState { } /// Store a decoded symbol dictionary for later reference. - fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) { + fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.symbol_dictionaries.push((segment_number, dictionary)); + Ok(()) + } + + /// PdfCraft patch: the symbols of the dictionaries in `segments`, in order, refusing past + /// [`MAX_REFERRED_SYMBOLS`] before anything is collected. + fn referred_symbols(&self, segments: &[u32]) -> Result> { + let dictionaries = || segments.iter().filter_map(|&num| self.get_symbol_dictionary(num)); + let count = dictionaries() + .map(|dictionary| dictionary.exported_symbols.len() as u64) + .fold(0, u64::saturating_add); + if count > MAX_REFERRED_SYMBOLS { + bail!(OverflowError::SymbolCount); + } + Ok(dictionaries().flat_map(|dictionary| dictionary.exported_symbols.iter()).collect()) } /// Look up a symbol dictionary by segment number. @@ -650,8 +727,10 @@ impl PageState { } /// Store a decoded Huffman table for later reference. - fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) { + fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) -> Result<()> { + self.retain(table.words())?; self.huffman_tables.push((segment_number, table)); + Ok(()) } /// Look up a Huffman table by segment number. @@ -690,3 +769,51 @@ fn init_page( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A page past the bounds is refused when the stream is opened, since callers size their + /// output from it before decoding starts. 16,384 squared is exactly the bound on pixels. + #[test] + fn a_page_past_the_bounds_is_refused_when_opened() { + let stream = |width: u32, height: u32| { + let mut stream = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + stream.extend_from_slice(&width.to_be_bytes()); + stream.extend_from_slice(&height.to_be_bytes()); + stream.extend_from_slice(&[0; 11]); // resolution, flags, striping + stream + }; + assert!(Image::new_embedded(&stream(16384, 16384), None).is_ok()); + assert!(Image::new_embedded(&stream(16385, 16384), None).is_err()); + assert!(Image::new_embedded(&stream(65536, 1), None).is_err(), "too wide, though few pixels"); + assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); + } + + /// A stream and its globals are one stream of segments, so they have one count. + #[test] + fn the_segments_of_a_stream_and_its_globals_count_together() { + let segment: &[u8] = &[0, 0, 0, 0, 62, 0, 1, 0, 0, 0, 0]; // an empty extension + let half = segment.repeat(1 << 19); + let more = segment.repeat((1 << 19) + 1); + let refused = |data: &[u8]| { + let error = Image::new_embedded(data, Some(&half)).err().map(|e| e.to_string()); + error.as_deref() == Some("too many segments or references in a stream") + }; + assert!(!refused(&half), "2^20 segments are allowed"); + assert!(refused(&more), "one more isn't"); + } + + /// A dictionary being decoded has what the page may still hold, not all of it: refused only + /// once it is stored, its copies would already have been made. + #[test] + fn the_room_left_shrinks_as_segments_are_stored() { + let mut page = PageState::default(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS); + page.retain(1000).unwrap(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS - 1000 - ENTRY_WORDS); + page.retained_words = MAX_RETAINED_WORDS; + assert_eq!(page.retained_room(), 0); + } +} diff --git a/vendor/hayro-syntax/src/data.rs b/vendor/hayro-syntax/src/data.rs index 9d5e2030f8..90c44f89c9 100644 --- a/vendor/hayro-syntax/src/data.rs +++ b/vendor/hayro-syntax/src/data.rs @@ -1,5 +1,4 @@ use crate::object::ObjectIdentifier; -use crate::object::Stream; use crate::reader::ReaderContext; use crate::sync::HashMap; use crate::sync::{Arc, Mutex, MutexExt}; @@ -92,6 +91,12 @@ impl Data { &self.data } + /// PdfCraft patch: (hayro-syntax (33)) enter the gate for what an object stream's index depends + /// on, once its data is read and [`Self::get_with`] has let go of it. + pub(crate) fn enter_loading(&self, id: ObjectIdentifier) -> Option> { + self.loading.enter(id) + } + /// Get access to the data of a decoded object stream. pub(crate) fn get_with(&self, id: ObjectIdentifier, ctx: &ReaderContext<'_>) -> Option<&[u8]> { // PdfCraft patch: (#307, see vendor/README.md hayro-syntax (10)) register exactly once: @@ -110,7 +115,8 @@ impl Data { let _loading = self.loading.enter(id)?; self.decoded .get_or_init(idx, || { - let stream = ctx.xref().get_with::>(id, ctx)?; + // PdfCraft patch: (hayro-syntax (33)) only ever an object stream. + let stream = ctx.xref().object_stream(id, ctx)?; stream.decoded().ok().map(Cow::into_owned) })? .as_deref() @@ -244,7 +250,7 @@ mod tests { } } -struct LoadGuard<'a>(&'a LoadGate); +pub(crate) struct LoadGuard<'a>(&'a LoadGate); impl Drop for LoadGuard<'_> { fn drop(&mut self) { diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index d87253f536..6f1bfb0768 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -1,4 +1,5 @@ use crate::bit_reader::BitWriter; +use crate::filter::Filter; use crate::object::Dict; use crate::object::Stream; use crate::object::dict::keys::JBIG2_GLOBALS; @@ -17,8 +18,12 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { + // PdfCraft patch: globals are raw segments, so JBIG2 inside them means nothing, and decoding + // it reads its own globals in turn: a chain of 16,000 such streams overflowed the stack, which + // aborts the process. Such a stream reads as globals that can't be decoded. let globals = params .get::>(JBIG2_GLOBALS) + .filter(|g| !g.filters().iter().any(|f| matches!(f, Filter::Jbig2Decode))) .and_then(|g| g.decoded().ok()); let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?; diff --git a/vendor/hayro-syntax/src/object/array.rs b/vendor/hayro-syntax/src/object/array.rs index d39230f7db..fb136e867a 100644 --- a/vendor/hayro-syntax/src/object/array.rs +++ b/vendor/hayro-syntax/src/object/array.rs @@ -191,6 +191,12 @@ impl<'a> FlexArrayIter<'a> { self.reader.skip_white_spaces_and_comments(); if !self.reader.at_end() { + // PdfCraft patch (32): its items each follow a reference. Read as one that might be, a + // reference at the start was taken for the whole group, which it can't be, and lost. + if T::GROUP { + return self.reader.read_with_context::(&self.ctx); + } + return match self.reader.read_with_context::>(&self.ctx)? { MaybeRef::Ref(r) => self.ctx.xref().get_with::(r.into(), &self.ctx), MaybeRef::NotRef(i) => Some(i), diff --git a/vendor/hayro-syntax/src/object/mod.rs b/vendor/hayro-syntax/src/object/mod.rs index d692db8b38..6ad97b64e4 100644 --- a/vendor/hayro-syntax/src/object/mod.rs +++ b/vendor/hayro-syntax/src/object/mod.rs @@ -32,7 +32,11 @@ pub mod dict; pub mod stream; /// A trait for PDF objects. -pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone {} +pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone { + /// PdfCraft patch (32): whether this is several items of an array, read one by one (a tuple). + /// Such a thing can't itself be given by reference, so one at its start is its first item's. + const GROUP: bool = false; +} /// A trait for PDF objects that can be borrowed directly from an [`Object`]. pub(crate) trait ObjectRefLike<'a>: Sized { diff --git a/vendor/hayro-syntax/src/object/ref.rs b/vendor/hayro-syntax/src/object/ref.rs index 71951f4d82..3d3cbffbe1 100644 --- a/vendor/hayro-syntax/src/object/ref.rs +++ b/vendor/hayro-syntax/src/object/ref.rs @@ -39,10 +39,11 @@ impl From for ObjRef { impl Skippable for ObjRef { fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> { + // PdfCraft patch: (hayro-syntax (33)) a comment counts as white space, here too. r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(()) @@ -52,9 +53,9 @@ impl Skippable for ObjRef { impl Readable<'_> for ObjRef { fn read(r: &mut Reader<'_>, _: &ReaderContext<'_>) -> Option { let obj_ref = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); let gen_num = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(Self::new(obj_ref, gen_num)) diff --git a/vendor/hayro-syntax/src/object/tuple.rs b/vendor/hayro-syntax/src/object/tuple.rs index 4a7190e916..c76c1b408c 100644 --- a/vendor/hayro-syntax/src/object/tuple.rs +++ b/vendor/hayro-syntax/src/object/tuple.rs @@ -39,6 +39,7 @@ where T: ObjectLike<'a> + Debug + Clone, U: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V> Readable<'a> for (T, U, V) @@ -78,6 +79,7 @@ where U: ObjectLike<'a> + Debug + Clone, V: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W> Readable<'a> for (T, U, V, W) @@ -122,6 +124,7 @@ where V: ObjectLike<'a> + Debug + Clone, W: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W, X> Readable<'a> for (T, U, V, W, X) @@ -171,4 +174,5 @@ where W: ObjectLike<'a> + Debug + Clone, X: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 745af95556..d3ab7d324f 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -4,6 +4,7 @@ use crate::crypto::{DecryptionError, DecryptionTarget, Decryptor, get}; use crate::data::Data; use crate::metadata::Metadata; use crate::object::Name; +use crate::object::Number; use crate::object::ObjectIdentifier; use crate::object::Stream; use crate::object::dict::keys::{ @@ -12,7 +13,7 @@ use crate::object::dict::keys::{ }; use crate::object::dict::{DictCache, probe_dict}; use crate::object::indirect::IndirectObject; -use crate::object::{Array, MaybeRef}; +use crate::object::{Array, MaybeRef, ObjRef}; use crate::object::{DateTime, Dict}; use crate::object::{Object, ObjectLike}; use crate::pdf::PdfVersion; @@ -31,6 +32,17 @@ use core::ops::Deref; pub(crate) const XREF_ENTRY_LEN: usize = 20; +/// PdfCraft patch: (hayro-syntax (33)) how many references a read follows through objects that hold +/// nothing else. Past it, as any cycle goes, the object reads as a missing one. +const MAX_REFERENCE_HOPS: usize = 32; + +/// PdfCraft patch: (hayro-syntax (33)) what [`XRef::fetch`] found at an object's place. +enum Fetched { + Object(T), + /// All the object holds is a reference to this one. + Reference(ObjectIdentifier), +} + #[derive(Debug, Copy, Clone)] pub(crate) enum XRefError { Unknown, @@ -69,6 +81,65 @@ pub(crate) fn fallback(data: PdfData, password: &[u8]) -> Option { } } +/// PdfCraft patch: an `N G obj` header for the repair scan, taking a generation number that +/// cannot be read as 0. +/// +/// A generation number is at most 65,535 (ISO 32000-2 §7.5.4), so `3 18446744073709551616 obj` is +/// malformed and [`ObjectIdentifier::read`] is right to refuse it everywhere else. This scan runs +/// only when a file's cross-reference table is unusable and its objects are being recovered by +/// reading the file, where refusing the header drops the object and whatever it holds (#5580). +/// Before #427 the digits wrapped, and 2^64 wrapped to exactly 0, which is the only reason such a +/// file used to open. +/// +/// Only here: reading numbers anywhere else is unchanged. +fn read_object_header<'a>(r: &mut Reader<'a>, ctx: &ReaderContext<'a>) -> Option { + let mut strict = r.clone(); + if let Some(obj_id) = strict.read::(ctx) { + *r = strict; + return Some(obj_id); + } + // On a clone as well, so a header that isn't one leaves `r` where it was for the caller. + let mut lenient = r.clone(); + let obj_number = lenient.read_without_context::()?; + lenient.skip_white_spaces_and_comments(); + // Any number at all in the generation position, including one no generation may be. + lenient.skip::(false)?; + lenient.skip_white_spaces_and_comments(); + lenient.forward_tag(b"obj")?; + *r = lenient; + Some(ObjectIdentifier::new(obj_number, 0)) +} + +/// PdfCraft patch: the body of the object at `offset` whose `N G obj` header cannot be read +/// strictly, for a table rebuilt by scanning the file (#5580). The steps are +/// `IndirectObject::read`'s, with [`read_object_header`] in place of the strict identifier. +fn read_repaired_body<'a, T>( + data: &'a [u8], + offset: usize, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, +) -> Option +where + T: ObjectLike<'a>, +{ + let mut r = Reader::new(data); + r.jump(offset); + // The number is what identifies the object; the generation is what could not be read. + if read_object_header(&mut r, ctx)?.obj_number != id.obj_number { + return None; + } + let mut ctx = ctx.clone(); + if ctx.parent_chain_contains(&id) { + warn!("cycle detected in indirect object: {id:?}"); + + return None; + } + ctx.set_obj_number(id); + ctx.parent_chain_push(id); + r.skip_white_spaces_and_comments(); + r.read_with_context::(&ctx) +} + fn fallback_xref_map<'a>(data: &'a PdfData, password: &[u8]) -> (XrefMap, Option>) { fallback_xref_map_inner(data, ReaderContext::dummy(), true, password) } @@ -80,6 +151,10 @@ fn fallback_xref_map_inner<'a>( password: &[u8], ) -> (XrefMap, Option>) { let mut xref_map = FxHashMap::default(); + // PdfCraft patch: the generation-0 keys below that stand in for a header with another + // generation, so a later header can replace one while a real `N 0 obj` is never displaced + // (noted in review of #5580). + let mut aliases: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); let mut trailer_dicts = vec![]; let mut root_ref = None; @@ -94,12 +169,32 @@ fn fallback_xref_map_inner<'a>( // First try to check if we have an object identifier. if r.peek_byte().is_some_and(|b: u8| b.is_ascii_digit()) { - if let Some(obj_id) = r.read::(&dummy_ctx) { + if let Some(obj_id) = read_object_header(&mut r, &dummy_ctx) { let mut cloned = r.clone(); // Check that the object following it is actually valid before inserting it. cloned.skip_white_spaces_and_comments(); if cloned.skip::>(false).is_some() { xref_map.insert(obj_id, EntryType::Normal(cur_pos)); + // PdfCraft patch: also reachable by object number alone, so a reference that + // names a different generation than the header still finds it. A rebuilt + // table has no revision history to tell two generations apart, and a file + // that needs rebuilding references everything as `N 0 R` whatever its headers + // say: `poppler-85140-0.pdf` has one page whose header reads + // `3 18446744073709551616 obj` with a page tree pointing at `3 0 R`, so the + // page was lost and the file did not open at all (#5580). `or_insert`, so a + // real `N 0 obj` is never displaced by this alias. + let by_number = ObjectIdentifier::new(obj_id.obj_number, 0); + if obj_id.gen_number == 0 { + // A real `N 0 obj`: it owns the key from here on, and the last one wins + // as every other entry does. + aliases.remove(&by_number); + } else if !xref_map.contains_key(&by_number) || aliases.contains(&by_number) { + // Stand in for it, and let a later header for the same number replace the + // stand-in -- the exact key moves on, so an alias left behind would send + // `N 0 R` to a body the exact key no longer names (noted in review). + xref_map.insert(by_number, EntryType::Normal(cur_pos)); + aliases.insert(by_number); + } last_obj_num = Some(obj_id); dummy_ctx.set_obj_number(obj_id); } @@ -487,12 +582,24 @@ impl XRef { // Try to yield in the order the objects appeared in the // PDF. elements.sort_by(|e1, e2| e1.1.cmp(&e2.1)); + // PdfCraft patch: one object per location. A rebuilt table can hold two keys for + // one object -- its own generation and the generation-0 stand-in (#5580) -- and + // enumeration yielded the object once for each, so a document recovered by + // `Pages::new_brute_force` reported the page twice (noted in review). Both keys + // stay for lookups; only this walk is deduplicated. The object number is part of + // the key: an object stream at (offset, 0) and its first member, also + // (offset, 0), are two objects. + elements.dedup_by(|a, b| a.1 == b.1 && a.0.obj_number == b.0.obj_number); let mut iter = elements.into_iter(); iter::from_fn(move || { for next in iter.by_ref() { - if let Some(obj) = self.get_with(next.0, &ReaderContext::new(self, false)) { + // PdfCraft patch: (hayro-syntax (33)) an object that only refers to another + // isn't one of its own: followed, that one came out once more for each. + if let Some(Fetched::Object(obj)) = + self.fetch(next.0, &ReaderContext::new(self, false)) + { return Some(obj); } else { // Skip invalid objects. @@ -563,12 +670,48 @@ impl XRef { } /// Return the object with the given identifier. + /// + /// PdfCraft patch: (hayro-syntax (33)) an object that holds only a reference stands for what + /// that names. Read as a number it was the reference's own object number, and as anything else + /// it was lost. This is a loop, so a long chain takes no stack. #[allow(private_bounds)] pub(crate) fn get_with<'a, T>( &'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>, ) -> Option + where + T: ObjectLike<'a>, + { + let mut id = id; + for _ in 0..=MAX_REFERENCE_HOPS { + match self.fetch::(id, ctx)? { + Fetched::Object(object) => return Some(object), + Fetched::Reference(next) => id = next, + } + } + warn!("references lead on past the limit, or in a cycle: {id:?}"); + + None + } + + /// PdfCraft patch: (hayro-syntax (33)) the object stream `id`. It has to be a stream stored as + /// that object (ISO 32000-2 7.5.7), so this follows no reference: the check of where it is stored + /// is of that object, and one reached through a reference could be a stream stored inside + /// another. + pub(crate) fn object_stream<'a>( + &'a self, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, + ) -> Option> { + match self.fetch::>(id, ctx)? { + Fetched::Object(stream) => Some(stream), + Fetched::Reference(_) => None, + } + } + + /// What is stored at the object with the given identifier. + fn fetch<'a, T>(&'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>) -> Option> where T: ObjectLike<'a>, { @@ -597,11 +740,32 @@ impl XRef { ctx.set_in_object_stream(false); r.jump(offset); + if let Some(next) = held_reference(r.clone(), id) { + return Some(Fetched::Reference(next)); + } + if let Some(object) = r.read_with_context::>(&ctx) { - if object.id() == &id { - return Some(object.get()); + // PdfCraft patch: in a table rebuilt by scanning the file, the object number + // is what identifies an object. There is no revision history to tell two + // generations apart, the scan already files objects found in object streams + // under generation 0, and a malformed generation in a header would otherwise + // make the object unreadable through the reference that names it (#5580). A + // table read from the file's own xref keeps the exact match. + if object.id() == &id || (repaired && object.id().obj_number == id.obj_number) { + return Some(Fetched::Object(object.get())); } } else { + // PdfCraft patch: the strict read of the header failed. In a table rebuilt by + // scanning the file that can be a generation number no generation may be -- + // `3 18446744073709551616 obj` -- and refusing it loses the object the + // reference names (#5580). The object number still has to be the one asked + // for; only an unreadable generation is tolerated, and only here. + if repaired + && let Some(object) = + read_repaired_body::(repr.data.get().as_ref(), offset, id, &ctx) + { + return Some(Fetched::Object(object)); + } // There is a valid object at the offset, it's just not of the type the caller // expected, which is fine. if r.skip::>>(false).is_some() { @@ -622,7 +786,7 @@ impl XRef { self.repair(); // Now try reading again. - self.get_with::(id, &ctx) + self.fetch::(id, &ctx) } } EntryType::ObjStream(obj_stram_gen_num, index) => { @@ -657,11 +821,20 @@ impl XRef { // A stream whose pairs don't parse stays unreadable, as it was. Some(offsets) => offsets?, None => { - let stream = self.get_with::>(obj_stream_id, &ctx)?; + // PdfCraft patch: (hayro-syntax (33)) this read can reach another object + // stream, whose `/N` or `/First` can be a member of the next: the gate's + // limit holds here too, or a chain of them ran out of stack. It reads the + // dictionary, which is all it needs: parsing the stream reads `/Length` + // too, a second way to the next stream's index, which doubled at each. + let _loading = repr.data.enter_loading(obj_stream_id)?; + let Fetched::Object(dict) = self.fetch::>(obj_stream_id, &ctx)? + else { + return None; + }; // `/N` and `/First` may be references that can't be followed while // another stream loads: reading them fails without being remembered. - let num_objects = stream.dict().get::(N)?; - let first_offset = stream.dict().get::(FIRST)?; + let num_objects = dict.get::(N)?; + let first_offset = dict.get::(FIRST)?; let parsed = read_object_stream_index(num_objects, first_offset, data).map(Arc::new); self.with_object_stream_indexes(|indexes| { @@ -673,7 +846,12 @@ impl XRef { .unwrap_or(parsed)? } }; - ObjectStream::with_offsets(data, &ctx, offsets).get(index) + let stream = ObjectStream::with_offsets(data, &ctx, offsets); + if let Some(next) = stream.reference(index) { + return Some(Fetched::Reference(next)); + } + + stream.get(index).map(Fetched::Object) } } } @@ -1232,6 +1410,28 @@ impl<'a> ObjectStream<'a> { r.read_with_context::(&self.ctx) } + + /// PdfCraft patch: (hayro-syntax (33)) what the object at `index` refers to, when a reference is + /// all it holds. + fn reference(&self, index: u32) -> Option { + let offset = self.offsets.get(index as usize)?.1; + let mut r = Reader::new(self.data); + r.jump(offset); + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) + } +} + +/// PdfCraft patch: (hayro-syntax (33)) what the indirect object `id` at `r` refers to, when a +/// reference is all it holds. +fn held_reference(mut r: Reader<'_>, id: ObjectIdentifier) -> Option { + if r.read_without_context::()? != id { + return None; + } + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) } fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { @@ -1266,6 +1466,203 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { #[cfg(test)] mod tests { use super::*; + use crate::Pdf; + + /// A file with an xref stream: `stored` are objects on their own, as they would follow + /// `n 0 obj`, and `packed` says of each object in an object stream which stream and place. + fn with_xref_stream(stored: &[(u32, Vec)], packed: &[(u32, u32, u16)]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = alloc::collections::BTreeMap::new(); + for (num, body) in stored { + offsets.insert(*num, out.len()); + out.extend_from_slice(alloc::format!("{num} 0 obj ").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let last = stored.iter().map(|o| o.0).chain(packed.iter().map(|o| o.0)).max().unwrap_or(0); + let (xref, size) = (last + 1, last + 2); + let at = out.len(); + offsets.insert(xref, at); + let packed: alloc::collections::BTreeMap = packed.iter().map(|o| (o.0, (o.1, o.2))).collect(); + let mut rows = Vec::new(); + for num in 0..size { + if let Some(offset) = offsets.get(&num) { + rows.push(1); + rows.extend_from_slice(&(*offset as u32).to_be_bytes()); + rows.extend_from_slice(&[0, 0]); + } else if let Some((stream, index)) = packed.get(&num) { + rows.push(2); + rows.extend_from_slice(&stream.to_be_bytes()); + rows.extend_from_slice(&index.to_be_bytes()); + } else { + rows.extend_from_slice(&[0; 7]); + } + } + out.extend_from_slice( + alloc::format!( + "{xref} 0 obj << /Type /XRef /Size {size} /W [1 4 2] /Root 1 0 R /Length {} >> stream\n", + rows.len() + ) + .as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(alloc::format!("\nendstream endobj\nstartxref\n{at}\n%%EOF\n").as_bytes()); + out + } + + /// `/Type /ObjStm` around `members`, each stored at its offset, and `entries` of its own. + fn object_stream_of(members: &[(u32, &[u8])], entries: &str) -> Vec { + let (mut head, mut body) = (alloc::string::String::new(), Vec::new()); + for (num, member) in members { + head.push_str(&alloc::format!("{num} {} ", body.len())); + body.extend_from_slice(member); + body.push(b'\n'); + } + head.push('\n'); + let dict = alloc::format!( + "<< /Type /ObjStm /N {} /First {} /Length {} {entries} >> stream\n", + members.len(), + head.len(), + head.len() + body.len() + ); + let mut out = dict.into_bytes(); + out.extend_from_slice(head.as_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(b"\nendstream"); + out + } + + fn page_objects() -> Vec<(u32, Vec)> { + vec![ + (1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()), + (2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec()), + (3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_vec()), + ] + } + + /// An object stream is a stream stored as that object, however many references lead to one. + /// Object 10 holds a reference to 11, a stream stored inside object stream 12, and 31 claims to + /// be in 10, with 11's `/N`, `/First` or `/Length` being 31: reading it recursed until the + /// stack ran out. It reads as missing, and so does a member of such a stream. + #[test] + fn an_object_stream_isnt_reached_through_a_reference() { + for key in ["N", "First", "Length"] { + let inner = alloc::format!("<< /Type /ObjStm /{key} 31 0 R >> stream\n31 0 1\n\nendstream"); + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner.as_bytes())], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (31, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(31, 0)), None, "{key}"); + } + let inner = b"<< /Type /ObjStm /N 1 /First 5 /Length 8 >> stream\n20 0 7\n\nendstream"; + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner)], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (20, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(20, 0)), None); + } + + /// `count` object streams stored as themselves, the `keys` of each a member of the next, or + /// with `ring` of the first after the last. Reading the first member reads them all, and it is + /// `count + 100` to tell. + #[cfg(feature = "std")] + fn chain_of_object_streams(count: u32, keys: &[&str], ring: bool) -> Vec { + let mut stored = page_objects(); + let mut packed = Vec::new(); + for i in 0..count { + let (stream, member) = (100 + 2 * i, 101 + 2 * i); + let value = if i == 0 { count + 100 } else { 1 }; + let data = alloc::format!("{member} 0 \n{value}\n"); + let (first, length) = (alloc::format!("{}", alloc::format!("{member} 0 \n").len()), alloc::format!("{}", data.len())); + let next = match (i + 1 == count, ring) { + (true, true) => "101 0 R".into(), + (true, false) => "1".into(), + _ => alloc::format!("{} 0 R", member + 2), + }; + let entry = |key, own: &'_ str| if keys.contains(&key) { next.clone() } else { own.into() }; + let (n, first, length): (alloc::string::String, alloc::string::String, alloc::string::String) = + (entry("N", "1"), entry("First", &first), entry("Length", &length)); + let body = alloc::format!("<< /Type /ObjStm /N {n} /First {first} /Length {length} >> stream\n{data}\nendstream"); + stored.push((stream, body.into_bytes())); + packed.push((member, stream, 0)); + } + with_xref_stream(&stored, &packed) + } + + /// A chain of object streams, each with a member of the next for its `/N`, `/First` or + /// `/Length`, had no limit: one read recursed through all of them, holding a copy of the chain + /// at each, and 50,000 ran out of an 8 MiB stack. It stops at the gate's limit, on half the stack + /// of a render worker. Past that `/N` and `/First` can't be read, so the member reads as missing, + /// and a `/Length` that can't be read falls back on `endstream`, so it reads. A short chain reads. + #[cfg(feature = "std")] + #[test] + fn a_chain_of_object_streams_ends_at_the_limit_of_nested_loads() { + for key in ["N", "First", "Length"] { + let pdf = chain_of_object_streams(50_000, &[key], false); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let read = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + assert_eq!(read, (key == "Length").then_some(50_100), "{key}"); + }) + .unwrap() + .join() + .unwrap(); + } + let pdf = Pdf::new(chain_of_object_streams(10, &["N"], false)).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(101, 0)), Some(110)); + } + + /// `/Length` and `/N` or `/First` both by reference were two ways to the next stream's index, + /// and an index that fails isn't remembered, so the work doubled at each stream: 65 streams, one + /// past the limit, in 8 KB, didn't finish, and other threads waited at the gate. The index reads + /// the dictionary alone now. Without that this doesn't end, so the time allowed can't fail it. + #[cfg(feature = "std")] + #[test] + fn a_chain_of_object_streams_takes_no_more_work_for_two_ways_on() { + for (count, ring) in [(65, false), (20, true), (5_000, false)] { + for keys in [&["Length", "N"][..], &["Length", "First"]] { + let pdf = chain_of_object_streams(count, keys, ring); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let _ = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + let _ = done.send(()); + }) + .unwrap(); + assert!(finished.recv_timeout(std::time::Duration::from_secs(120)).is_ok(), "{keys:?} of {count}"); + } + } + } + + /// Object 5, a member of a real object stream, holds a reference to object 6 stored on its own, + /// and 7, stored on its own, to 8, a member: each reads as what it names. + #[test] + fn a_reference_held_in_either_storage_is_followed() { + let mut stored = page_objects(); + stored.push((6, b"42".to_vec())); + stored.push((7, b"8 0 R".to_vec())); + stored.push((9, object_stream_of(&[(5, b"6 0 R"), (8, b"43")], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(5, 9, 0), (8, 9, 1)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(5, 0)), Some(42)); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(7, 0)), Some(43)); + } + + /// With no page tree, every page object is a page, and one that another object only refers to + /// is one page, not two. + #[test] + fn a_page_a_reference_is_held_to_is_found_once() { + let mut stored = page_objects(); + stored[0].1 = b"<< /Type /Catalog /Pages 99 0 R >>".to_vec(); + stored[2].1 = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R >>".to_vec(); + stored.push((4, b"<< /Length 0 >> stream\n\nendstream".to_vec())); + stored.push((12, b"3 0 R".to_vec())); + let pdf = Pdf::new(with_xref_stream(&stored, &[])).unwrap(); + assert_eq!(pdf.pages().len(), 1); + } // PdfCraft patch tests (ported from craft-libs): xref stream entries are bounded by the // decoded data, and the type field is read at its full width. @@ -1446,6 +1843,18 @@ mod tests { } } + #[test] + fn an_object_stream_and_its_first_member_are_both_enumerated() { + // Both sit at (stream offset, 0) in `objects`' ordering; one object per location must + // not cost either of them. + let pdf = crate::Pdf::new(object_stream_fixture(false)).unwrap(); + let objects: Vec<_> = pdf.xref().objects().into_iter().collect(); + assert!(objects.iter().any(|o| matches!(o, Object::Stream(_)))); + assert!(objects.iter().any( + |o| matches!(o, Object::Dict(d) if d.get::(b"Marker") == Some(123)) + )); + } + #[test] fn recursive_object_stream_locations_fail_without_recursing() { let pdf = crate::Pdf::new(object_stream_fixture(true)).unwrap(); diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index ac103fa197..0b9cfe779e 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -401,30 +401,55 @@ fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> boo paints_canvas(device, width, height) } -/// PdfCraft patch: the whole-pixel device rectangle of a shrunk, axis-aligned image, as -/// `(width, height, x0, y0)`. `transform` maps source pixels to device pixels and `planned` is the -/// size [`image_resampling_size`] chose; `None` (draw as before) for rotated, skewed or flipped -/// images, non-finite geometry, or a snapped size that strays more than a pixel from `planned` -/// (so the memory budget that size passed still holds). +/// PdfCraft patch: how far, in device pixels, the terms [`snap_minified_image`] drops may move an +/// image's far edges: a hundredth of a pixel, which no edge shows. Rounding noise in a turned or +/// scaled matrix stays far below it. +const SNAP_SKEW: f64 = 0.01; + +/// PdfCraft patch: the whole-pixel device rectangle of a shrunk image whose axes lie along the +/// device's, as `(width, height, transform)`: the size to shrink it to (along its own axes) and +/// the transform that draws that raster 1:1, one source-axis step a whole device pixel. Upright, +/// mirrored and quarter-turned images all qualify, so a page under `/Rotate` (or an image placed +/// turned) is drawn as sharply as upright (#5530). `transform` maps source pixels to device +/// pixels and `planned` is the size [`image_resampling_size`] chose; `None` (draw as before) for +/// skewed or freely rotated images, non-finite geometry, or a snapped size that strays more than +/// a pixel from `planned` (so the memory budget that size passed still holds). An image counts +/// as axis-aligned when the terms the snap drops move its far edges by at most [`SNAP_SKEW`]: a +/// tolerance on the coefficients alone let a slight skew of a very wide image through, though it +/// moved the far edge by more than a pixel. fn snap_minified_image( transform: &Affine, source: (u32, u32), planned: (u32, u32), -) -> Option<(u32, u32, f64, f64)> { +) -> Option<(u32, u32, Affine)> { let [a, b, c, d, e, f] = transform.as_coeffs(); - let tiny = 1e-4 * a.abs().max(d.abs()); - if !(a > 0.0 && d > 0.0 && b.abs() <= tiny && c.abs() <= tiny) { - return None; - } + // The device span of a source edge from `origin` across `side` samples of `scale`, rounded. let edge = |origin: f64, scale: f64, side: u32| { - let (lo, hi) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (lo, hi) = (p.min(q), p.max(q)); (hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi)) }; - let (x0, x1) = edge(e, a, source.0)?; - let (y0, y1) = edge(f, d, source.1)?; - // Bounded by the 1e6 check above, so the casts cannot saturate. - let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); - (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0)) + // Along an axis drawn backwards (negative scale), the raster starts at the span's far end. + let start = |scale: f64, (lo, hi): (f64, f64)| if scale > 0.0 { lo } else { hi }; + let step = |scale: f64| if scale > 0.0 { 1.0 } else { -1.0 }; + // How far a dropped term moves the image's far edge: `scale` across `side` source samples. + let negligible = |scale: f64, side: u32| (scale * f64::from(side)).abs() <= SNAP_SKEW; + let (width, height) = source; + // Source x along device x, source y along device y (upright or mirrored), or a quarter turn: + // source x along device y, source y along device x. + let (w, h, draw) = if negligible(b, width) && negligible(c, height) { + let (x, y) = (edge(e, a, source.0)?, edge(f, d, source.1)?); + // Bounded by the 1e6 check above, so the casts cannot saturate. + let (w, h) = ((x.1 - x.0) as u32, (y.1 - y.0) as u32); + (w, h, Affine::new([step(a), 0.0, 0.0, step(d), start(a, x), start(d, y)])) + } else if negligible(a, width) && negligible(d, height) { + let (x, y) = (edge(e, c, source.1)?, edge(f, b, source.0)?); + let (w, h) = ((y.1 - y.0) as u32, (x.1 - x.0) as u32); + (w, h, Affine::new([0.0, step(b), step(c), 0.0, start(c, x), start(b, y)])) + } else { + return None; + }; + (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, draw)) } /// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while @@ -960,10 +985,11 @@ impl Renderer { if requested_resize && self.in_type3_glyph && interpolate { quality = ImageQuality::High; } - // PdfCraft patch: an axis-aligned, interpolated image that is shrunk lands on whole device - // pixels. It is shrunk once to exactly its snapped device rectangle and drawn 1:1 at the - // snapped corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a - // second time at a fractional scale and offset, which blurs scans and photos. + // PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's + // (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to + // exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the + // shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale + // and offset, which blurs scans and photos. let snapped = if interpolate && requested_resize && !self.in_type3_glyph @@ -1122,9 +1148,12 @@ impl Renderer { img_height as u16, may_have_transparency, ); + // The snapped transform draws the raster 1:1, so it holds only if the shrink produced the + // snapped size. A failed resize hands back the source raster: drawn 1:1 it would come out + // at its source size, so it keeps the full transform instead. let draw_transform = match snapped { - Some((_, _, x0, y0)) => Affine::translate((x0, y0)), - None => cur_transform * additional_transform, + Some((w, h, draw)) if (img_width, img_height) == (w, h) => draw, + _ => cur_transform * additional_transform, }; self.draw_pixmap(Arc::new(pixmap), quality, draw_transform); } @@ -2581,3 +2610,28 @@ pub fn tiling_cell_pixels(scale: f32, step: f32, max_pixels: f32) -> (u16, f64) // (`exact` is), so the division is too. (pixels as u16, pixels / step) } + +#[cfg(test)] +mod snap_tests { + use super::{snap_minified_image, Affine}; + + /// The skew a snap drops is measured as the distance it moves the image's far edge, not as a + /// coefficient against the scale: a 40 000 sample wide image skewed by 4e-5 per sample moves + /// its far edge 1.6 device pixels, and must keep its full transform (#5640 review). + #[test] + fn a_skew_that_moves_a_far_edge_is_not_snapped() { + let source = (40_000, 400); + let planned = (20_000, 200); + // Upright, shrunk to half, sheared so the far end sits 1.6 pixels lower: not snapped. + let skewed = Affine::new([0.5, 4e-5, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&skewed, source, planned).is_none()); + // A quarter turn with the same stray term along device x: not snapped either. + let turned = Affine::new([4e-5, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned, source, (20_000, 200)).is_none()); + // Rounding noise far below a hundredth of a pixel at the far edge still snaps. + let noisy = Affine::new([0.5, 1e-9, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&noisy, source, planned).is_some()); + let turned_noisy = Affine::new([1e-9, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some()); + } +}