diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be9e2c448e9..adfab6e4be7 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" +sha256 = "8b52e6cca01de70943f3cb25086fd0771d286b1f1c0a3f0ac0e9a6827b19a216" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f" +sha256 = "a6d4a53c4d024e5eba99950eb21910b03954dc3015a811e8947f166624554b67" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" +sha256 = "ae6d4ba215b61a79ff18fd41bb0ec70aeceaaa73910aba967a44786ffc4db639" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517" +sha256 = "feadc03c09687dcf3d874a58c79a734023b4725be50bdcbc411f6a89eac7c505" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543" +sha256 = "4ea0d6d4e0fafdcdd712b50dca5851d9266ba488d86bf2649b96e24f7fac6a7d" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1" +sha256 = "f8dc2fdcfcfe1bd4220b060535d60145553c3ed39369d271144d40860f47f6ff" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12" +sha256 = "094b7fa25593a2f794ef6798044302bbe57c0fdad5d7610c4273b620a694050c" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb" +sha256 = "ca263a06674d68a5ee75e1d534bd8aea29e6203537a5feb61e9742b119cd84f7" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" +sha256 = "25328d89b23419e14f7d244e395a07e51c4d6d821215f2f31c2cc0932f7a6dd5" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b" +sha256 = "dd56005f132ed25020944ba4f95b30bad12ec82d4138a7af7da8114ac89ab5ad" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" +sha256 = "3df23b552709976f91336f2c32ffcc6e65ea734d20c104554a56ae9d76ede525" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452" +sha256 = "32ed5d3422be044ec2b15ea677e863125999e169e6d62b243c9d6e55c95aa78e" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587" +sha256 = "13a46e70f9a69fdb54a597facdbe92460ca7e5d5c2029e28c86b007ddf4155c2" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3" +sha256 = "71ba0f8de937aa4efd4503fe045fe198392181953d2a8247d8d6da639ccfa7ac" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c" +sha256 = "a608985b6f488d412918db23df29bcd88e56a2263e6f1f678473b86755a93569" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa" +sha256 = "a0c5c198109e74cd49d8c287ec7a315d6501727f6b405d7405a385f85670ea57" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac" +sha256 = "c2bfc376b6157df592c2ae4269179d8d5647d307afb09f0861fc6d783e5591fa" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679" +sha256 = "ae82b6562ec0f7f8514963b8cd4a1a699e854b3952eb7a9d291ad5ef3bbbd5b4" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf" +sha256 = "c5c895aad95e6c76b76eace05968deca6e1a653afe098359143c1bbe9c563785" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa" +sha256 = "5ccd19fe6d23ae177cfef3763ef48184baa850d5557eea6d86d8d45e25b7e104" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6" +sha256 = "9dba61cdc88a9eafa2f21233113ff500519d1863a55a6e2648d1cf87b922d236" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0" +sha256 = "401a81dcec3866ae1a250bb0c08e3d4147fbdb1d8bc9c0b7e05f2272afd91223" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index 91ed3c49048..c6f4f49d69e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" @@ -3933,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-quit", "eframe", @@ -3958,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3970,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3981,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3992,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4013,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4029,18 +4034,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-content" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4054,7 +4059,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.8.0" +version = "0.9.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4070,7 +4075,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "md-5", @@ -4082,7 +4087,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4097,9 +4102,10 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", @@ -4135,14 +4141,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flate2", "proptest", @@ -4152,7 +4158,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.8.0" +version = "0.9.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4162,7 +4168,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4174,18 +4180,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-js" -version = "0.8.0" +version = "0.9.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4198,14 +4204,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4215,7 +4221,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "pdfcraft-annot", @@ -4228,11 +4234,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-organize" -version = "0.8.0" +version = "0.9.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4243,7 +4249,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.8.0" +version = "0.9.0" dependencies = [ "async-channel", "futures-lite", @@ -4253,7 +4259,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4262,7 +4268,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4273,7 +4279,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4289,7 +4295,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytemuck", "flate2", @@ -4308,7 +4314,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes", "aws-lc-rs", @@ -4342,11 +4348,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.8.0" +version = "0.9.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4390,7 +4396,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -4402,7 +4408,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "image", @@ -4416,7 +4422,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 597dd316fa7..ec890980216 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.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +# 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.8.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.8.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.8.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.8.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.8.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" } -pdfcraft-model = { path = "crates/model", version = "0.8.0" } -pdfcraft-content = { path = "crates/content", version = "0.8.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.8.0" } -pdfcraft-print = { path = "crates/print", version = "0.8.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } -pdfcraft-js = { path = "crates/js", version = "0.8.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" } -pdfcraft-export = { path = "crates/export", version = "0.8.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.8.0" } -pdfcraft-create = { path = "crates/create", version = "0.8.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.8.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" } -pdfcraft-render = { path = "crates/render", version = "0.8.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.8.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.8.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.9.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.9.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.9.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.9.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.9.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.9.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.9.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" } +pdfcraft-model = { path = "crates/model", version = "0.9.0" } +pdfcraft-content = { path = "crates/content", version = "0.9.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.9.0" } +pdfcraft-print = { path = "crates/print", version = "0.9.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.9.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" } +pdfcraft-js = { path = "crates/js", version = "0.9.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.9.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" } +pdfcraft-export = { path = "crates/export", version = "0.9.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.9.0" } +pdfcraft-create = { path = "crates/create", version = "0.9.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.9.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" } +pdfcraft-render = { path = "crates/render", version = "0.9.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.9.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.9.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec045..62c85d14112 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,12 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -157,6 +162,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. +- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. +- **2026-10-17 (M7, CAD exports):** Pages drawn as one Form XObject (WSCAD) can be edited: Edit text and images ▸ Ungroup (context menu or double-click; `image_edit` action `ungroup`) puts the form's content on the page exactly as §8.10.1 paints it (matrix, BBox clip, clashing resource names renamed, open state closed), one undo step; transparency groups whose blending would change, tagged forms and other default colour spaces are refused with the reason. Type 0 fonts with `/ToUnicode /Identity-H` (a name, not a CMap) read codes as Unicode in the renderer's text layer (vendored hayro patch 35) and in `crates/fonts`, so such pages can be selected, searched and edited; before, they had no text. New `parity` entry `edit.form-content-editing` (partial). Estimates unchanged. +- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. +- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. @@ -168,6 +179,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. +- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. + - **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. @@ -495,6 +509,10 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | +| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | @@ -516,7 +534,10 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | +| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. - **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). + +- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb204328..4d9857ce19b 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -231,7 +231,10 @@ fn main() -> eframe::Result { // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own // window. let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; - let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { + // Absolute for this instance too: links relative to a document resolve against its folder, + // never the working directory. + let files = single_instance::absolute(&files); + let instance = match single_instance::claim(&files, may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a77c..ebbfdd4767b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,6 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Lcoding15] +real_name = "Livio Oswald" diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ed36a8d5eec..63d9220ccae 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -500,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() { let result = audit_redactions(&open(nested_forms(33))); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn comment_across_streams_neither_invents_text_nor_a_cover() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f")); + assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect(); + assert!(!text.contains("After comment"), "editor exposed phantom text: {text}"); +} + +#[test] +fn reading_blocks_respect_stream_comments_and_split_operands() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Before comment"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Split operand"); + // Marked content opened in one stream applies to text in the next: its /ActualText replaces + // the glyphs, as when the streams are read joined. + for (s1, s2) in [ + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC (Glyphs) Tj EMC ET".as_slice()), + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC", b"(Glyphs) Tj EMC ET"), + ] { + let doc = open(two_streams(s1, s2)); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); + } +} diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1e8d71bd1c7..8130ed084d3 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -918,11 +918,16 @@ impl Automation { let list: Vec = found .iter() .map(|(p, c)| { - let kind = match c.kind { - pdfcraft_engine::detect::Kind::Text => "text", - pdfcraft_engine::detect::Kind::CheckBox => "checkbox", + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), }; - json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }) + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v }) .collect(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 710952d7c26..faefc5fa7c4 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -138,7 +138,7 @@ impl Automation { .added() .iter() .filter(|x| x.page == page) - .nth((index - 1).max(0) as usize) + .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX)) .filter(|_| index >= 1) .ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?; Ok((page, index as usize - 1, it.content.clone(), ph)) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b20..a6ba831bef3 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -283,6 +283,13 @@ impl Automation { let to = self.position(&a, "to")?; self.apply(&a, Edit::MovePages { pages, to })? } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } "page_insert_blank" => self.insert_blank(&a)?, "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, @@ -504,6 +511,7 @@ impl Automation { "flip_horizontal" => ImageEdit::Flip { horizontal: true }, "flip_vertical" => ImageEdit::Flip { horizontal: false }, "delete" => ImageEdit::Delete, + "ungroup" => ImageEdit::Ungroup, "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; @@ -3101,4 +3109,23 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// A recovered document, or a new one, rotated and then undone still has never been saved: + /// `doc_close` refuses it unless told to discard it (#2346 review). + #[test] + fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a9..53f6668b041 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -224,6 +224,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), &["doc", "pages", "to"], )), + t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.") + .cmd("page.reverse") + .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])), t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") .cmd("page.insert_blank") .with(schema( @@ -1098,7 +1101,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") @@ -1241,14 +1244,14 @@ 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), 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), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place), delete, or ungroup (Form artwork only: its content becomes separate page objects, so its text and images can be edited one by one; a form whose blending, tags or default colours would change is refused with the reason). 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", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete", "ungroup"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38e..e80315dcb33 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +#[test] +fn reversing_pages_through_tools() { + let dir = workdir("reverse"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_reverse", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document"); + ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] })); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); @@ -2666,6 +2681,68 @@ fn editing_form_artwork_through_tools() { assert_eq!(a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(), rendered_before); } +/// A page drawn as one Form XObject (as CAD exports draw it): `image_edit` ungroup puts its text +/// and line work on the page, so the text becomes a paragraph, without changing the rendering. +#[test] +fn ungrouping_form_artwork_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; + let dir = workdir("form-ungroup"); + let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&cos)[0].clone(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let font = cos.add(Object::Dict(font)); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 1600, 2400].map(Object::Int).to_vec())); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(font)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = + cos.add(Object::Stream(Stream::flate(form, b"6 w 100 100 m 1500 100 l S BT 60 0 0 60 160 2000 Tm /F1 1.4 Tf (Raumregulierung) Tj ET"))); + let contents = cos.add(Object::Stream(Stream::flate(Dict::new(), b"q 0.12 0 0 0.12 0 0 cm /Page Do Q"))); + cos.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + let mut xo = Dict::new(); + xo.set(b"Page".to_vec(), Object::Ref(artwork)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + std::fs::write(dir.join("cad.pdf"), write_incremental(&cos, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "cad.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0]["kind"], "form"); + let paragraphs = |a: &mut Automation| -> Vec { + ok(a, "text_paragraphs", json!({"doc": doc, "page": 1}))["paragraphs"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["text"].as_str().map(str::to_owned)) + .collect() + }; + assert!(paragraphs(&mut a).is_empty(), "the text is inside the group"); + let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(); + + ok(&mut a, "image_edit", json!({"doc": doc, "page": 1, "image": 1, "action": "ungroup"})); + assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["count"], 0); + assert_eq!(paragraphs(&mut a), ["Raumregulierung"]); + assert_eq!(a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(), rendered, "the page looks the same"); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "out/ungrouped.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path": "out/ungrouped.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc": re, "page": 1}))["count"], 0); + + ok(&mut a, "edit_undo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "page_images", json!({"doc": doc, "page": 1}))["images"][0]["kind"], "form"); + // Nothing left to ungroup on the saved copy: a clear error, not a change. + assert!(a.call("image_edit", &json!({"doc": re, "page": 1, "image": 1, "action": "ungroup"})).is_err()); +} + #[test] fn auditing_space_through_tools() { let dir = workdir("audit"); @@ -3582,6 +3659,24 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable +/// failure for `content_update` and `content_delete`, not a panic. +#[test] +fn content_index_out_of_range_is_an_error_not_a_panic() { + let dir = workdir("content-index-range"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] })); + for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] { + for tool in ["content_delete", "content_update"] { + let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" }); + let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args }; + assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}"); + } + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] @@ -4959,6 +5054,41 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_comb_boxes_through_tools() { + let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("detect-comb"); + std::fs::write(dir.join("comb.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc })); + assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001"); +} + #[test] fn comparing_documents_through_tools() { let dir = workdir("compare"); diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 32334e047bb..c7c8bca9eeb 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before + /// and whether one runs out of it into the next. Only boundaries are recorded, so the size + /// depends on the number of pieces, not of comments. + comment_edges: Vec<(bool, bool)>, } fn is_ws(b: u8) -> bool { @@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool { /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { - let mut out = Parsed::default(); + parse_pieces(data, &[]) +} + +/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment +/// crosses. +fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { + let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; + let mut boundary = 0; let mut lx = Lexer::new(data, 0); let mut operands: Vec = Vec::new(); let mut start: Option = None; loop { + let trivia = lx.pos; lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + } let Some(&b) = data.get(lx.pos) else { break }; let at = lx.pos; let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); @@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed { out } +/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and +/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward. +fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) { + let mut at = trivia.start; + while at < trivia.end { + if data.get(at) != Some(&b'%') { + at += 1; + continue; + } + let comment = at; + while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) { + at += 1; + } + // The separator of piece `boundary` is the last byte before its end. + while let Some(&end) = ends.get(*boundary) { + let separator = end.saturating_sub(1); + if separator >= at { + break; + } + if separator >= comment { + if let Some(edge) = edges.get_mut(*boundary) { + edge.1 = true; + } + // Includes a comment whose end of line is the next piece's first byte. + if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) { + edge.0 = true; + } + } + *boundary += 1; + } + } +} + /// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds /// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { @@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec { /// A page's content streams joined into the one stream they are. Writers may split it between /// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its /// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back -/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together. +/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro, +/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece +/// runs on into the next one, up to its first end of line. #[derive(Clone, Debug, Default, PartialEq)] pub struct Pieces { data: Vec, @@ -319,7 +371,7 @@ impl Pieces { let mut ends = Vec::with_capacity(pieces.len()); for p in pieces { data.extend_from_slice(p.as_ref()); - data.push(b'\n'); + data.push(b' '); ends.push(data.len()); } Pieces { data, ends } @@ -341,12 +393,22 @@ impl Pieces { /// Parse the pieces as one stream. pub fn parse(&self) -> Parsed { - parse(&self.data) + parse_pieces(&self.data, &self.ends) + } + + /// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs + /// into it from the piece before, and whether one runs out of it into the next. A writer + /// that rewrites a piece without its comments must keep both: end the incoming comment + /// before its first operator, and leave a comment open at its end. + pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> { + let mut edges = parsed.comment_edges.clone(); + edges.resize(self.len(), (false, false)); + edges } /// The piece holding joined position `at` (the last piece for positions past the end). pub fn piece_of(&self, at: usize) -> usize { - self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1)) + self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1)) } /// The pieces an operator spans: (the one its first token is in, the one its keyword is in). diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 18e0b8cbcc5..1119bdaf903 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() { assert_eq!(out.len(), 3); assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty()); } + +#[test] +fn stream_boundary_comment_matches_space_concatenation() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut tail = b"(HIDDEN) Tj".to_vec(); + tail.extend(eol); + tail.extend(b"(LIVE) Tj"); + let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]); + let parsed = pieces.parse(); + let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE"); + } +} + +#[test] +fn comment_edges_terminate_at_next_piece_byte_zero() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut next = eol.to_vec(); + next.extend_from_slice(b"q Q"); + let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + } +} + +#[test] +fn comment_edges_span_empty_pieces_and_end_of_content() { + let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + + let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]); + assert!(parsed.ops.is_empty()); + + let pieces = Pieces::join::<&[u8]>(&[]); + assert!(pieces.comment_edges(&pieces.parse()).is_empty()); +} + +#[test] +fn comment_edges_ignore_percent_inside_operands_and_image_data() { + for first in [ + b"(literal % not a comment) Tj".as_slice(), + b"/Percent#25Name MP", + b"[(literal % not a comment)] TJ", + b"/P << /ActualText (literal % not a comment) >> BDC EMC", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI", + ] { + let pieces = Pieces::join(&[first, b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}"); + assert_eq!(parsed.ops.last().unwrap().op, b"Q"); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn compound_operand_comment_split_is_owned_by_spanning_operator() { + for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] { + let pieces = Pieces::join(&parts); + let parsed = pieces.parse(); + // The object lexer consumes this nested comment. Its containing operator spans + // both pieces, which forces both to be rewritten together by the redactor. + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]); + assert_eq!(parsed.ops.len(), 1); + assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1)); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() { + let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]); +} + +#[test] +fn comment_edge_storage_depends_on_piece_count_not_comment_count() { + let mut first = b"%\n".repeat(16_384); + first.extend_from_slice(b"q % crossing"); + let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]); + assert_eq!(parsed.ops.len(), 2); + // Also exercise public parsing without piece-boundary metadata. + assert_eq!(parse(&first).ops.len(), 1); +} + +#[test] +fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { + // Joined layout: qQ; separator belongs to prior piece. + let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]); + for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] { + assert_eq!(pieces.piece_of(at), expected, "position {at}"); + } + let empty = Pieces::join::<&[u8]>(&[]); + assert_eq!(empty.piece_of(0), 0); + assert_eq!(empty.piece_of(usize::MAX), 0); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb7..e943555905d 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -337,6 +337,40 @@ impl PartialEq for SourceIdentity { impl Eq for SourceIdentity {} +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + source: SourceIdentity, + trailer: Dict, + overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + #[derive(Clone)] pub struct Document { data: Arc>, @@ -801,6 +835,12 @@ impl Document { SourceIdentity(Arc::downgrade(&self.source_identity)) } + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + pub fn version(&self) -> &str { &self.version } @@ -1113,7 +1153,19 @@ impl Document { if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } - let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) }; + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize; let data = s.decoded_within(self.stream_limit)?; @@ -1935,6 +1987,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); + } + /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); @@ -2535,6 +2609,93 @@ mod tests { assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream + /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. + fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = vec![ + [0, 0, 0, 255], + [1, 0, o1 as u8, 0], + [1, 0, o2 as u8, 0], + [2, 0, 99, 0], + [2, 0, 3, 0], + [1, (out.len() >> 8) as u8, out.len() as u8, 0], + ]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + /// An object stream whose own cross-reference entry says it is compressed inside another + /// stream (which the format forbids, and which does not exist here: qpdf's + /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it + /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own + /// header, as a plain object with a wrong offset is. + #[test] + fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); + } + + /// The header fallback is for file entries only: once the stream is freed (or replaced) in + /// the overlay, its objects must not come back from the file's copy of it. + #[test] + fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + } + + /// The same once the free is saved: the revision's table says the stream is free, and + /// neither reading the file back nor rewriting it in full brings its objects back. + #[test] + fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } + } + #[test] fn damaged_xref_is_reconstructed() { let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 213ff341bec..152daed2de0 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod security; mod writer; pub use bytes::Bytes; -pub use document::{Document, Revision, SourceIdentity, XrefEntry}; +pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index dc7648d4f99..438b8d5f353 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -482,6 +482,19 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"abc"); } + #[test] + fn raw_flate_fallback_streams_preserve_the_caller_limit() { + // Raw deflate with a stored byte and a final empty block. 78 01 also + // passes the zlib header check, so decoding must retry the whole input. + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]); + let result = stream.decoded_within(0); + assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}"); + assert_eq!(stream.decoded_within(1).unwrap(), b"A"); + assert_eq!(stream.decoded_strict().unwrap(), b"A"); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa83..5717977b1ea 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f39..a996b703f3b 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + 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 a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbfc3..9816dfc4b2e 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have -//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page -//! (stamps, added images, replacements) go through here first. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::{Edit, MarkFile}; - - /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 - /// degrees clockwise). - fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { - let mut jpeg = Vec::new(); - let mut encoder = JpegEncoder::new(&mut jpeg); - encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); - encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); - 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 - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } - - #[test] - fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { - let mut session = crate::Session::new(); - let blank = session.create_blank(300.0, 400.0, 1).unwrap(); - let id = session.open_new("form.pdf", blank).unwrap(); - // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. - // Named "Signature" so `signature_image` finds its picture. - let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; - session.apply(id, stamp).unwrap(); - let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; - let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); - let obj = cos.get(picture); - let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); - let rect = pdfcraft_annot::summaries(cos)[0].rect; - assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); - } } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fb621258809..823def98d50 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -30,8 +30,8 @@ pub struct PageImage { /// Pixel size; zero for vector/grouped Form artwork. pub width: u32, pub height: u32, - stream: usize, - op: usize, + pub(crate) stream: usize, + pub(crate) op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], clips: Vec, @@ -71,7 +71,7 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -216,7 +216,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The graphics state carries over from one stream to the next. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { + for (si, (_, data)) in content_streams(doc, &p.dict).into_iter().enumerate() { for (i, op) in parse(&data).ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -361,7 +361,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut forms = Forms { path: Vec::new(), visits: 0 }; - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { + for (si, (_, data)) in content_streams(doc, &p.dict).into_iter().enumerate() { walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?; } Ok(out) @@ -449,7 +449,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict); let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; let mut ops = parse(&data).ops; if ops.get(img.op).is_none() { @@ -503,7 +503,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict); Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -747,7 +747,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict); let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; @@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("the image move is too large".into())); }; plans.remove(&(stream, index)); - clips.insert((stream, at), c); + // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the + // same CTM as `prev` followed by another `cm c`, without one more operator per move. + let folded = at + .checked_sub(1) + .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?))))) + .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)); + match folded { + Some((i, m)) => clips.insert((stream, i), (m, false)), + None => clips.insert((stream, at), (c, true)), + }; } // 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(); @@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize folds.insert(at, folded); } let changed = crate::text::splice(&pieces, &ops, |index| { - if let Some(c) = clips.remove(&(stream, index)) { - return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); + if let Some((c, keep)) = clips.remove(&(stream, index)) { + return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); } if let Some(m) = folds.remove(&index) { return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 515f17d8dae..52385148d8e 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -327,7 +327,37 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio } const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; -const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; +/// Starts with an end of line, so a comment left open at the end of the content before it +/// can't swallow the `Q`. Older files have the close without it. +const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; +const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; + +/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped +/// content left open, or [`OLD_WRAP_CLOSE`]. +fn is_wrap_close(bytes: &[u8]) -> bool { + let mut rest = bytes; + loop { + if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE { + return true; + } + match rest.strip_prefix(b"\nQ".as_slice()) { + Some(shorter) => rest = shorter, + None => return false, + } + } +} + +/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` +/// for a close. +fn wrapper_role(doc: &Document, o: &Object) -> Option { + if stream_tag(doc, o).as_deref() != Some("Wrap") { + return None; + } + let Object::Stream(stream) = &*doc.resolve(o) else { return None }; + // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. + let bytes = stream.decoded_within(64 * 1024).ok()?; + if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } +} #[cfg(test)] fn stream_bytes(doc: &Document, o: &Object) -> Option> { @@ -350,27 +380,138 @@ fn tagged(tag: &str) -> Dict { d } +/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, +/// which another writer may keep, copy or forge). +enum Isolation { + /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. + /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) + /// that a mark on top must not inherit, so it needs a wrapper from there to the end. + /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. + Clean { suffix: Option, wrappers: Vec<(usize, usize)> }, + /// The content could not be read that precisely (it doesn't decode, is too large, or has + /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is + /// the count of `q` left open at the end, when known. + Unknown { unclosed: usize }, +} + +const ISOLATION_MAX_STREAMS: usize = 100_000; +const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; +const ISOLATION_MAX_OPS: usize = 1_000_000; +const ISOLATION_MAX_DEPTH: usize = 10_000; + +fn isolation(doc: &Document, list: &[Object]) -> Isolation { + let unknown = Isolation::Unknown { unclosed: 0 }; + if list.len() > ISOLATION_MAX_STREAMS { + return unknown; + } + let mut remaining = ISOLATION_MAX_BYTES; + let mut data = Vec::with_capacity(list.len()); + for object in list { + let resolved = doc.resolve(object); + let Object::Stream(stream) = &*resolved else { return unknown }; + let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; + let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; + remaining = left; + data.push(bytes); + } + let pieces = pdfcraft_content::Pieces::join(&data); + let parsed = pieces.parse(); + if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { + return unknown; + } + let edges = pieces.comment_edges(&parsed); + let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); + let mut stack: Vec = Vec::new(); + let mut split = 0; + let mut previous_last = None; + let mut wrappers = Vec::new(); + for op in &parsed.ops { + let (first, last) = pieces.pieces_of(op); + // A wrapper can only go in before a whole stream, at the top level, between operators, + // and where no comment runs into the stream (it would swallow the wrapper's `q`). + if stack.is_empty() + && !text_open + && !path_open + && !dirty + && previous_last.is_none_or(|previous| first > previous) + && !edges.get(first).is_some_and(|edge| edge.0) + { + split = first; + } + previous_last = Some(last); + match op.op.as_slice() { + b"BT" if !text_open => text_open = true, + b"ET" if text_open => text_open = false, + b"BT" | b"ET" => malformed = true, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, + _ => {} + } + match op.op.as_slice() { + b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), + b"Q" if op.operands.is_empty() => match stack.pop() { + Some(open) => { + // Only a standalone PdfCraft wrapper pair may later be removed again. + let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); + if wrap(open) + && wrap(first) + && data.get(open).is_some_and(|d| d == WRAP_OPEN) + && data.get(first).is_some_and(|d| is_wrap_close(d)) + { + wrappers.push((open, first)); + } + } + None => malformed = true, + }, + b"q" | b"Q" => malformed = true, + _ if stack.is_empty() => dirty = true, + _ => {} + } + } + if malformed || text_open || path_open || !stack.is_empty() { + return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } }; + } + let comment_open = edges.last().is_some_and(|edge| edge.1); + Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers } +} + /// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the /// original content in q/Q). 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) } -/// Put content on a page, tagged `tag` (see [`place`]). +/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the +/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including +/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of +/// content that leaves state set is wrapped in q/Q, once. fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { let mut list = contents(doc, page)?; + let wrap = if behind { + None + } else { + match isolation(doc, &list) { + Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)), + // Content that can't be read precisely is wrapped whole, as before: after the + // behind-marks at the start. A `q` it leaves open is closed at its end, where + // closing it changes nothing already drawn. + Isolation::Unknown { unclosed } => { + let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; + let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len()); + (split < list.len()).then_some((split, unclosed)) + } + } + }; let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); if behind { list.insert(0, mark); } else { - let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); - let original = list.iter().any(|o| stream_tag(doc, o).is_none()); - if !wrapped && original { + if let Some((split, unclosed)) = wrap { + let mut close_bytes = b"\nQ".repeat(unclosed); + close_bytes.extend_from_slice(WRAP_CLOSE); let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); - let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec())))); - // Behind-marks stay outside the wrapper. - let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len()); - list.insert(split, open); + let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); + list.insert(split.min(list.len()), open); list.push(close); } list.push(mark); @@ -613,8 +754,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl Ok(()) } -/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on -/// top of a page, the q/Q wrapper goes too, restoring the original content list. +/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with +/// nothing after them go too, restoring the original content list; a wrapper that content +/// after it still relies on (another tool's appended stream) stays. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -625,12 +767,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); removed += before - kept.len(); - // Marks on top come after the closing wrapper; without any, the wrapper has no purpose. - let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap"); - let close = kept.iter().rposition(&is_wrap); - let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap"))); - if !marks_on_top { - kept.retain(|o| !is_wrap(o)); + // Strip only wrapper pairs whose close ends the list (after stripping the pairs after + // it): nothing comes after them to need the state they restore. Pairs come from the + // operators; for content that can't be read that precisely, from the wrapper streams + // alone, so updating a mark there doesn't nest another wrapper each time. + if before != kept.len() { + let wrappers = match isolation(doc, &kept) { + Isolation::Clean { wrappers, .. } => wrappers, + Isolation::Unknown { .. } => { + let mut opens = Vec::new(); + let mut pairs = Vec::new(); + for (i, o) in kept.iter().enumerate() { + match wrapper_role(doc, o) { + Some(true) => opens.push(i), + Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), + None => {} + } + } + pairs + } + }; + let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); + let mut discarded = std::collections::HashSet::new(); + let mut end = kept.len(); + while let Some(close) = end.checked_sub(1) { + if discarded.contains(&close) { + end = close; + } else if let Some(&open) = pairs.get(&close) { + discarded.insert(open); + discarded.insert(close); + end = close; + } else { + break; + } + } + kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); } if before != kept.len() { doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; @@ -651,6 +822,8 @@ 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}; +pub mod ungroup; +pub use ungroup::ungroup_form; pub mod bidi; pub mod indic; mod linebreak; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df84..8f73c26d08c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4,6 +4,8 @@ use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; +mod ungroup; + /// 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 { @@ -77,7 +79,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() let s0 = streams(&doc, 0); // q-wrapper, original, Q-wrapper, header/footer. assert_eq!(s0.len(), 4, "{s0:?}"); - assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n")); + // The close starts on a new line, so a comment the original ends in can't swallow it. + assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); let mark = &s0[3]; assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}"); assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity"); @@ -1920,6 +1923,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() { } } +#[test] +fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { + // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`. + let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"); + let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut first = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + if i == 0 { + first = operators(&doc); + } + } + let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let now = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect); + assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect); + assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one"); + assert_eq!(q_depth(&doc), 1); + let doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap()))); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption, ["Caption"]); +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); @@ -4249,3 +4277,226 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +/// Small graphics-state oracle for synthetic streams: every tracked state change must +/// have been restored when a probe XObject would draw. No external assets are involved. +fn probe_states(doc: &Document) -> Vec, Vec>> { + let bytes = streams(doc, 0); + let joined = pdfcraft_content::Pieces::join(&bytes); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().expect("balanced graphics state"), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +fn isolation_fixture(parts: &[(&str, &str)]) -> Document { + let mut doc = fixture(); + let page = page_list(&doc).remove(0); + let list = parts + .iter() + .map(|(tag, bytes)| { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec())))) + }) + .collect(); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + doc +} + +#[test] +fn new_marks_isolate_state_appended_after_old_wrappers() { + for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] { + for tag in ["", "OCR", "Wrap"] { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Added", "q /PCProbe Do Q"), + (tag, dirt), + ]); + let before = probe_states(&doc); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + let after = probe_states(&reopen(&doc)); + assert_eq!(&after[..before.len()], &before, "existing content must not change"); + assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}"); + } + } +} + +#[test] +fn new_marks_isolate_dirty_tagged_only_pages() { + let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() { + let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]); + for _ in 0..10 { + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + } + assert_eq!(streams(&doc, 0).len(), 13); + assert!(probe_states(&doc).iter().all(|s| s.is_empty())); +} + +#[test] +fn new_marks_close_wrapper_after_an_unterminated_comment() { + let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + // Check literal concatenation too: a separator must exist in the saved stream itself, + // not just in the inspection parser, or the comment can swallow the closing Q. + let bytes = streams(&doc, 0).concat(); + let parsed = pdfcraft_content::parse(bytes.as_bytes()); + assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() { + // Content that can't be isolated precisely is wrapped whole, as before #3084, instead of + // refusing: redaction stamps its boxes after removing the text, so it can't back out. + for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] { + let mut doc = isolation_fixture(&[("Wrap", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + let after = streams(&doc, 0); + assert_eq!(after.len(), 4, "{data}: {after:?}"); + assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}"); + // A `q` the content leaves open is closed after it, so the mark starts clean. A stray + // `Q` pops the wrapper itself: nothing placed after it can undo that. + let probe = probe_states_lenient(&doc); + assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}"); + } +} + +/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is, +/// as renderers do. +fn probe_states_lenient(doc: &Document) -> Vec, Vec>> { + let joined = pdfcraft_content::Pieces::join(&streams(doc, 0)); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap_or(state), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +#[test] +fn new_marks_preserve_cross_stream_operands_and_q_pairs() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn removing_marks_keeps_wrappers_needed_by_later_external_content() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q /PCProbe Do Q"), + ("", "/PCProbe Do"), + ]); + remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(); + assert_eq!(probe_states(&doc).len(), 1); + assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset"); +} + +#[test] +fn new_marks_wrap_unfinished_text_paths_and_operands_whole() { + for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] { + let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + // The behind-mark stays outside the wrapper and the content itself is untouched. + assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}"); + } +} + +#[test] +fn new_marks_do_not_reveal_cross_stream_comments() { + let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0)); + assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert"); +} + +#[test] +fn new_marks_follow_clean_streams_ending_in_comments() { + let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment"); + assert!(probe_states(&doc)[0].is_empty()); +} + +#[test] +fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() { + let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(probe_states(&doc).len(), 1); +} + +#[test] +fn removing_marks_strips_nested_trailing_wrapper_pairs() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q Q"), + ]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]); +} + +#[test] +fn updating_marks_on_unreadable_content_does_not_nest_wrappers() { + for data in ["q 50 Tz", "Q 50 Tz", "BT"] { + let mut doc = isolation_fixture(&[("", data)]); + let page = page_list(&doc).remove(0); + for _ in 0..3 { + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0)); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back"); + } + } +} + +#[test] +fn removing_marks_strips_wrappers_that_closed_open_state() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + // Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs. + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0)); + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); +} diff --git a/crates/edit/src/tests/ungroup.rs b/crates/edit/src/tests/ungroup.rs new file mode 100644 index 00000000000..8f7e50e9e97 --- /dev/null +++ b/crates/edit/src/tests/ungroup.rs @@ -0,0 +1,255 @@ +//! Ungroup: grouped Form artwork becomes page content (`crate::ungroup`). + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use super::{build, reopen, stream, streams}; +use crate::{BlockStyle, images, text, ungroup_form}; + +/// A one-page document whose page draws `form` (object 6) through `/Fm1`, with page font `/F1` +/// Courier (object 5) and `extra` objects from 7 on. +fn grouped_page(page_content: &str, form_dict: &str, form_content: &str, extra: &[&str]) -> Document { + let page = stream("", page_content); + let form = stream(&format!("/Type /XObject /Subtype /Form {form_dict}"), form_content); + let mut objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + &form, + ]; + objs.extend_from_slice(extra); + build(&objs) +} + +fn page_resources(doc: &Document) -> pdfcraft_cos::Dict { + let p = &pdfcraft_model::pages(doc)[0]; + doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap() +} + +fn category(doc: &Document, name: &[u8]) -> pdfcraft_cos::Dict { + doc.resolve(page_resources(doc).get(name).unwrap()).as_dict().cloned().unwrap() +} + +/// WSCAD and similar CAD exports draw a whole page as one Form XObject. Ungrouping puts its +/// content on the page, through its matrix and inside its bounding box, so its text becomes +/// editable paragraphs and its images separate objects, while the page keeps its own text. +#[test] +fn ungrouping_a_page_sized_form_makes_its_content_editable() { + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let mut doc = grouped_page( + "q 0.5 0 0 0.5 0 0 cm /Fm1 Do Q BT /F1 12 Tf 72 100 Td (Page text) Tj ET", + "/BBox [0 0 1200 1600] /Matrix [1 0 0 1 0 40] /Resources << /Font << /F2 7 0 R >> /XObject << /Im1 8 0 R >> >>", + "BT /F2 40 Tf 100 1400 Td (Schema title) Tj ET q 200 0 0 100 100 100 cm /Im1 Do Q", + &["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", &image], + ); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + assert!(before[0].is_form); + assert_eq!(text::text_blocks(&doc, 0).unwrap().iter().map(|b| b.text.as_str()).collect::>(), ["Page text"]); + + ungroup_form(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + + let blocks = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, ["Schema title", "Page text"]); + let title = &blocks[0]; + // (100, 1400) moved by the form's (0, 40), then halved by the page. + assert_eq!(title.base_font, "Helvetica"); + assert!((title.rect[0] - 50.0).abs() < 0.01 && (title.size - 20.0).abs() < 0.01, "{title:?}"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "{after:?}"); + assert!(!after[0].is_form); + let r = after[0].rect; + assert!((r[0] - 50.0).abs() < 0.01 && (r[1] - 70.0).abs() < 0.01 && (r[2] - 150.0).abs() < 0.01 && (r[3] - 120.0).abs() < 0.01, "{r:?}"); + // The form's bounding box clips the inlined content, as it clipped the form. + let content = streams(&doc, 0).concat(); + assert!(content.contains("0 40 cm") && content.contains("1200 1600 re W n"), "{content}"); + // The form object is unchanged; other users of it are not affected. + assert!(matches!(&*doc.get(ObjRef::new(6, 0)), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))); + + // The inlined paragraph is now editable like any other. + let mut edited = doc.clone(); + text::rewrite_block(&mut edited, 0, 0, Some("New title"), &BlockStyle::default()).unwrap(); + let edited = reopen(&edited); + assert!(text::text_blocks(&edited, 0).unwrap().iter().any(|b| b.text == "New title")); +} + +/// A form resource whose name the page uses for something else is renamed in the inlined content +/// only; names that mean the same thing are shared, and the page's own text keeps its font. +#[test] +fn ungrouping_renames_clashing_resources() { + let mut doc = grouped_page( + "/Fm1 Do BT /F1 12 Tf 72 100 Td (Page) Tj ET", + "/BBox [0 0 600 800] /Resources << /Font << /F1 7 0 R /F9 5 0 R >> /ExtGState << /G1 << /LW 3 >> >> >>", + "/G1 gs BT /F1 20 Tf 72 700 Td (Form) Tj /F9 10 Tf (Shared) Tj ET", + &["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"], + ); + ungroup_form(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let fonts = category(&doc, b"Font"); + assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(ObjRef::new(5, 0))), "the page's /F1 stays Courier"); + assert_eq!(fonts.get(b"F1_PC1"), Some(&Object::Ref(ObjRef::new(7, 0))), "{fonts:?}"); + assert_eq!(fonts.get(b"F9"), Some(&Object::Ref(ObjRef::new(5, 0)))); + let content = streams(&doc, 0).concat(); + assert!(content.contains("/F1_PC1 20 Tf") && content.contains("/F9 10 Tf") && content.contains("/G1 gs"), "{content}"); + let fonts_used: Vec<(String, String)> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| (b.text, b.base_font)).collect(); + assert!(fonts_used.contains(&("Page".into(), "Courier".into())), "{fonts_used:?}"); + assert!(fonts_used.iter().any(|(t, f)| t.starts_with("Form") && f == "Helvetica"), "{fonts_used:?}"); +} + +/// What a form leaves open at its end is closed when its content is inlined, so the page's own +/// operators after it keep their graphics state; what it closes without opening is refused. +#[test] +fn ungrouping_keeps_the_page_state_balanced() { + let mut doc = + grouped_page("/Fm1 Do BT /F1 12 Tf 72 100 Td (Page) Tj ET", "/BBox [0 0 600 800]", "q 2 0 0 2 0 0 cm q /OC /L1 BDC BT 1 0 0 rg", &[]); + ungroup_form(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let content = streams(&doc, 0).concat(); + assert!(content.contains("1 0 0 rg\nET\nEMC\nQ\nQ\nQ"), "{content}"); + let page = &text::text_blocks(&doc, 0).unwrap()[0]; + assert_eq!(page.text, "Page"); + assert!((page.rect[0] - 72.0).abs() < 0.01, "the page's text is not scaled by the form: {page:?}"); + + for (content, why) in [ + ("Q 0 0 1 rg", "restores more"), + ("BT ET ET", "did not start"), + ("EMC", "did not start"), + ("BT BT ET", "inside another"), + ("BT /Im1 Do ET", "inside a text object"), + ("(unterminated", "damaged"), + ] { + let mut doc = grouped_page("/Fm1 Do", "/BBox [0 0 600 800]", content, &[]); + let before = streams(&doc, 0); + let err = ungroup_form(&mut doc, 0, 0).unwrap_err().to_string(); + assert!(err.contains(why), "{content}: {err}"); + assert_eq!(streams(&doc, 0), before, "a refusal changes nothing"); + } + // A group drawn (invalidly) inside the page's own text object would nest text objects. + let mut doc = grouped_page("BT /F1 12 Tf /Fm1 Do ET", "/BBox [0 0 600 800]", "BT ET", &[]); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); + assert!(ungroup_form(&mut doc, 0, 0).unwrap_err().to_string().contains("drawn inside a text object")); +} + +/// A transparency group stays grouped when ungrouping would change its blending: a knockout +/// group, transparency inside it, another blending space, or alpha applied to the group as a +/// whole. A group around opaque content (what most exporters write) is ungrouped. +#[test] +fn ungrouping_refuses_groups_whose_blending_matters() { + let group = "/Group << /S /Transparency /I true >>"; + let mut plain = grouped_page("/Fm1 Do", &format!("/BBox [0 0 600 800] {group}"), "0 0 1 rg 10 10 100 100 re f", &[]); + ungroup_form(&mut plain, 0, 0).unwrap(); + + let cases = [ + "/BBox [0 0 600 800] /Group << /S /Transparency /K true >>", + "/BBox [0 0 600 800] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >>", + "/BBox [0 0 600 800] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /BM /Multiply >> >> >>", + "/BBox [0 0 600 800] /Group << /S /Transparency /CS /DeviceCMYK >>", + ]; + for dict in cases { + let mut doc = grouped_page("/Fm1 Do", dict, "/A gs 10 10 100 100 re f", &[]); + let err = ungroup_form(&mut doc, 0, 0).unwrap_err().to_string(); + assert!(err.contains("transparency group"), "{dict}: {err}"); + } + // Alpha set on the page before the group applies to the group as a whole. + let page = stream("", "/H gs /Fm1 Do"); + let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 600 800] {group}"), "10 10 100 100 re f 50 50 100 100 re f"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /ExtGState << /H << /CA 0.5 /ca 0.5 >> >> /XObject << /Fm1 5 0 R >> >> /Contents 4 0 R >>", + &page, + &form, + ]); + assert!(ungroup_form(&mut doc, 0, 0).unwrap_err().to_string().contains("transparency group")); +} + +/// Tagged forms, forms with other default colour spaces, forms without a box and images are not +/// ungrouped. +#[test] +fn ungrouping_refuses_what_it_cannot_keep() { + let mut tagged = grouped_page("/Fm1 Do", "/BBox [0 0 600 800] /StructParents 3", "BT ET", &[]); + assert!(ungroup_form(&mut tagged, 0, 0).unwrap_err().to_string().contains("accessibility")); + let mut tagged = grouped_page("/Fm1 Do", "/BBox [0 0 600 800] /StructParent 1", "BT ET", &[]); + assert!(ungroup_form(&mut tagged, 0, 0).unwrap_err().to_string().contains("accessibility")); + let mut defaults = + grouped_page("/Fm1 Do", "/BBox [0 0 600 800] /Resources << /ColorSpace << /DefaultRGB /DeviceGray >> >>", "1 0 0 rg 0 0 9 9 re f", &[]); + assert!(ungroup_form(&mut defaults, 0, 0).unwrap_err().to_string().contains("default colour spaces")); + // A form without a bounding box is not drawn, so it is not offered as a group at all. + let no_box = grouped_page("/Fm1 Do", "", "0 0 9 9 re f", &[]); + assert!(images::page_images(&no_box, 0).unwrap().is_empty()); + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let mut picture = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /XObject << /Im1 5 0 R >> >> /Contents 4 0 R >>", + &stream("", "q 9 0 0 9 0 0 cm /Im1 Do Q"), + &image, + ]); + assert!(ungroup_form(&mut picture, 0, 0).unwrap_err().to_string().contains("this is an image")); + assert!(ungroup_form(&mut picture, 0, 1).is_err()); + assert!(ungroup_form(&mut picture, 3, 0).is_err()); +} + +/// Optional content on the form marks the inlined content with the same membership, and an +/// inline image's colour space resource follows a rename. +#[test] +fn ungrouping_keeps_optional_content_and_inline_image_spaces() { + let mut doc = grouped_page( + "/Fm1 Do", + "/BBox [0 0 600 800] /OC 7 0 R /Resources << /ColorSpace << /F1 [/Indexed /DeviceRGB 1 <000000FFFFFF>] >> >>", + "q 10 0 0 10 0 0 cm BI /W 1 /H 1 /BPC 8 /CS /F1 ID \x01 EI Q", + &["<< /Type /OCG /Name (Layer) >>"], + ); + // The page's /F1 is a font, not a colour space: names in other categories don't clash. + ungroup_form(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let content = streams(&doc, 0).concat(); + assert!(content.contains("/OC /PCOC1 BDC") && content.trim_end().ends_with("EMC\nQ"), "{content}"); + assert!(content.contains("/CS /F1"), "{content}"); + assert_eq!(category(&doc, b"Properties").get(b"PCOC1"), Some(&Object::Ref(ObjRef::new(7, 0)))); + + let mut clash = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /ColorSpace << /S1 /DeviceGray >> /XObject << /Fm1 5 0 R >> >> /Contents 4 0 R >>", + &stream("", "/Fm1 Do"), + &stream( + "/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Resources << /ColorSpace << /S1 [/Indexed /DeviceRGB 1 <000000FFFFFF>] >> >>", + "q 10 0 0 10 0 0 cm BI /W 1 /H 1 /BPC 8 /CS /S1 ID \x01 EI Q /S1 cs 1 sc 0 0 5 5 re f", + ), + ]); + ungroup_form(&mut clash, 0, 0).unwrap(); + let content = streams(&clash, 0).concat(); + assert!(content.contains("/CS /S1_PC1") && content.contains("/S1_PC1 cs"), "{content}"); +} + +/// Ungrouping one placement leaves other pages that draw the same form, and other placements +/// on the same page, as they were. +#[test] +fn ungrouping_changes_only_the_chosen_placement() { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Font << /F2 7 0 R >> >>", "BT /F2 10 Tf 5 5 Td (Part) Tj ET"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>", + &stream("", "/Fm1 Do q 1 0 0 1 200 0 cm /Fm1 Do Q"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + &form, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /XObject << /Fm1 6 0 R >> >> /Contents 9 0 R >>", + &stream("", "/Fm1 Do"), + ]); + let other_before = streams(&doc, 1); + ungroup_form(&mut doc, 0, 1).unwrap(); + let doc = reopen(&doc); + let own = images::page_images(&doc, 0).unwrap(); + assert_eq!(own.len(), 1); + assert!(own[0].is_form && own[0].rect[0].abs() < 0.01, "the first placement stays a group: {own:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert!((blocks[0].rect[0] - 205.0).abs() < 0.01, "{blocks:?}"); + assert_eq!(streams(&doc, 1), other_before); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a394..916aaeadd94 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError path: Vec, visits: usize, } - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) { + /// `stream` gives the stream number of the operator at an index of `ops`. + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut invoked = Vec::new(); let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); let mut last = None; + let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); for s in shown { while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { @@ -1250,7 +1252,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError // Text after a form starts a new line. last = None; } - add_shown(&mut w.lines, &mut last, s, stream); + let si = stream(s.op); + // Text from another stream starts a new line, as when each stream was read alone. + if last_stream.replace(si).is_some_and(|prev| prev != si) { + last = None; + } + add_shown(&mut w.lines, &mut last, s, si); } for (_, name, ts) in forms { enter(w, resources, &name, ts); @@ -1266,7 +1273,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError if let Some(r) = form.obj { w.path.push(r); } - walk(w, &ops, &form.resources, &mut carry, stream); + walk(w, &ops, &form.resources, &mut carry, &|_| stream); if form.obj.is_some() { w.path.pop(); } @@ -1276,9 +1283,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError let streams = content_streams(doc, &p.dict); let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; let mut carry = Carry::new(); - for (si, (_, data)) in streams.into_iter().enumerate() { - walk(&mut w, &parse(&data).ops, &res, &mut carry, si); - } + // Read the page's streams as the one stream they are, in one pass, as rendering and editing + // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) + // can run on from one stream into the next. Text still counts as the stream its operator + // starts in, which keeps lines from different streams apart. + let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); + let ops = pieces.parse().ops; + let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); + walk(&mut w, &ops, &res, &mut carry, &stream_of); let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs new file mode 100644 index 00000000000..2dc2991e5dc --- /dev/null +++ b/crates/edit/src/ungroup.rs @@ -0,0 +1,428 @@ +//! Edit a PDF ▸ Ungroup: grouped Form artwork drawn by one `Do` in the page's own content becomes +//! ordinary page content, so its text, images and nested groups can be selected and edited one +//! by one. CAD and print exports (WSCAD, some plotter drivers) put a whole page in one Form +//! XObject, which otherwise leaves Edit a PDF with a single page-sized group. +//! +//! Painting a Form XObject is: save the graphics state, concatenate its `/Matrix`, clip to its +//! `/BBox`, run its content with its `/Resources`, restore (ISO 32000-2 §8.10.1). Ungrouping +//! writes exactly that in place of the `Do`: `q`, the matrix, the `/BBox` clip, the content and +//! `Q`. The content keeps its bytes; only resource names that clash with the page's are renamed. +//! The form object itself is left untouched, so other pages and annotations that draw it are not +//! affected. +//! +//! A form is refused, with a reason, where inlining could change how the page looks or what it +//! means: a transparency group that does more than wrap opaque content, a form that is part of the +//! structure tree (`/StructParent`, `/StructParents`), different default colour spaces, content +//! the parser had to skip, or a graphics state the content would leave unbalanced. + +use std::collections::BTreeMap; + +use pdfcraft_content::{Matrix, Op, Pieces, parse, write_op}; +use pdfcraft_cos::{Dict, Document, Object}; + +use crate::EditError; + +/// The largest decoded form content that is inlined. +const MAX_FORM_BYTES: usize = 64 * 1024 * 1024; +/// The most operators in one ungrouped form. +const MAX_FORM_OPS: usize = 2_000_000; +/// The deepest `q`/`BT`/marked-content nesting inside an ungrouped form. +const MAX_NESTING: usize = 1024; +/// How many fresh names are tried for one clashing resource. +const MAX_RENAME_TRIES: usize = 10_000; + +/// The resource categories whose names a content stream uses (§7.8.3). `ProcSet` holds no names. +const CATEGORIES: [&[u8]; 7] = [b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"XObject", b"Font", b"Properties"]; + +/// Colour space names that are operators' own, not resource names (§8.6.8, §8.9.7 for the +/// abbreviations an inline image may use). +const DEVICE_SPACES: [&[u8]; 8] = [b"DeviceGray", b"DeviceRGB", b"DeviceCMYK", b"Pattern", b"G", b"RGB", b"CMYK", b"I"]; + +fn invalid(message: &str) -> EditError { + EditError::Invalid(message.into()) +} + +fn resolved_dict(doc: &Document, o: Option<&Object>) -> Option { + o.map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) +} + +/// Whether two resource values name the same thing: the same object, or equal direct values. +fn same_resource(doc: &Document, a: &Object, b: &Object) -> bool { + match (a, b) { + (Object::Ref(x), Object::Ref(y)) => x == y, + (Object::Ref(_), _) | (_, Object::Ref(_)) => *doc.resolve(a) == *doc.resolve(b), + _ => a == b, + } +} + +/// A transparency-related setting in an ExtGState that makes a group's compositing differ from +/// painting its content directly: blend modes other than Normal, soft masks, alpha below 1 and +/// alpha-is-shape (§11.3–§11.6). +fn transparent_state(doc: &Document, gs: &Dict) -> bool { + let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).is_some_and(|a| a.is_nan() || a < 1.0); + let blend = gs.get(b"BM").map(|v| doc.resolve(v)).is_some_and(|bm| match &*bm { + Object::Name(n) => !matches!(n.as_slice(), b"Normal" | b"Compatible"), + Object::Array(a) => a.first().and_then(Object::as_name).is_none_or(|n| !matches!(n, b"Normal" | b"Compatible")), + _ => true, + }); + let mask = gs.get(b"SMask").map(|v| doc.resolve(v)).is_some_and(|m| m.as_name() != Some(b"None")); + let ais = gs.get(b"AIS").map(|v| doc.resolve(v)).is_some_and(|v| matches!(&*v, Object::Bool(true))); + alpha(b"CA") || alpha(b"ca") || blend || mask || ais +} + +/// Whether a group's own content composites only opaque paint: no transparent graphics state and +/// no soft-masked image among its direct resources. Nested forms stay groups of their own when +/// ungrouped, so their content does not matter here. +fn opaque_resources(doc: &Document, resources: &Dict) -> bool { + let states = resolved_dict(doc, resources.get(b"ExtGState")).unwrap_or_default(); + if states.iter().any(|(_, gs)| resolved_dict(doc, Some(gs)).is_none_or(|gs| transparent_state(doc, &gs))) { + return false; + } + let xobjects = resolved_dict(doc, resources.get(b"XObject")).unwrap_or_default(); + !xobjects.iter().any(|(_, x)| { + resolved_dict(doc, Some(x)) + .is_some_and(|d| d.name(b"Subtype") == Some(b"Image") && (d.contains(b"SMask") || d.int(b"SMaskInData").is_some_and(|v| v != 0))) + }) +} + +/// Whether the graphics state in effect at operator `target` of `ops` has transparency set by +/// `gs`, which would apply to a group as a whole but to each object once ungrouped. +fn transparent_at(doc: &Document, ops: &[Op], target: usize, states: &Dict) -> bool { + let mut current = false; + let mut stack = Vec::new(); + for op in ops.iter().take(target) { + match op.op.as_slice() { + b"q" => stack.push(current), + b"Q" => current = stack.pop().unwrap_or(current), + b"gs" => { + if let Some(gs) = op.name(0).and_then(|n| resolved_dict(doc, states.get(n))) { + // A later state can set alpha back to 1 or the blend mode back to Normal, but + // a soft mask or transparency set earlier may still apply; stay cautious. + current |= transparent_state(doc, &gs); + } + } + _ => {} + } + } + current +} + +/// Whether the group around a form's content changes how it looks once painted directly: +/// a knockout group, transparency inside it or applied to it, or a blending colour space other +/// than the page's. +fn group_matters(doc: &Document, group: &Dict, form_resources: &Dict, page: &Dict, page_ops: &[Op], target: usize, page_states: &Dict) -> bool { + if group.name(b"S") != Some(b"Transparency") { + return false; + } + let knockout = group.get(b"K").map(|v| doc.resolve(v)).is_some_and(|v| matches!(&*v, Object::Bool(true))); + let space = group.get(b"CS").map(|v| doc.resolve(v)); + let page_space = resolved_dict(doc, page.get(b"Group")).and_then(|g| g.get(b"CS").map(|v| doc.resolve(v))); + let same_space = match (&space, &page_space) { + (None, _) => true, + (Some(a), Some(b)) => a == b, + (Some(a), None) => a.as_name() == Some(b"DeviceRGB"), + }; + knockout || !same_space || !opaque_resources(doc, form_resources) || transparent_at(doc, page_ops, target, page_states) +} + +/// Resource names the inlined content uses that need a new name in the page's resources. +type Renames = BTreeMap<(&'static [u8], Vec), Vec>; + +/// Merge `form` resources into `page` resources. Names the page already uses for the same thing +/// are shared; names it uses for something else get a fresh name, returned in the map. +fn merge_resources(doc: &Document, page: &mut Dict, form: &Dict) -> Result { + let mut renames = Renames::new(); + for category in CATEGORIES { + let Some(theirs) = resolved_dict(doc, form.get(category)) else { continue }; + if theirs.is_empty() { + continue; + } + let mut ours = resolved_dict(doc, page.get(category)).unwrap_or_default(); + for (name, value) in theirs.iter() { + match ours.get(name) { + Some(existing) if same_resource(doc, existing, value) => {} + None => ours.set(name.clone(), value.clone()), + Some(_) => { + let fresh = (1..=MAX_RENAME_TRIES) + .map(|k| { + let mut n = name.clone(); + n.extend_from_slice(format!("_PC{k}").as_bytes()); + n + }) + .find(|n| !ours.contains(n) && !theirs.contains(n)) + .ok_or_else(|| invalid("the group's resource names cannot be merged into the page"))?; + ours.set(fresh.clone(), value.clone()); + renames.insert((category, name.clone()), fresh); + } + } + } + page.set(category.to_vec(), Object::Dict(ours)); + } + Ok(renames) +} + +/// Whether `op` names a resource in `category` as operand `index` (§8.4.5, Table 57 and the +/// operator tables of §8–§9, §14.6). +fn resource_operand(op: &Op) -> Option<(&'static [u8], usize)> { + let last = op.operands.len().checked_sub(1); + match op.op.as_slice() { + b"Tf" => Some((b"Font", 0)), + b"Do" => Some((b"XObject", 0)), + b"gs" => Some((b"ExtGState", 0)), + b"sh" => Some((b"Shading", 0)), + b"cs" | b"CS" => op.name(0).filter(|n| !DEVICE_SPACES.contains(n)).map(|_| (&b"ColorSpace"[..], 0)), + b"scn" | b"SCN" => last.filter(|i| op.name(*i).is_some()).map(|i| (&b"Pattern"[..], i)), + b"BDC" | b"DP" => op.name(1).map(|_| (&b"Properties"[..], 1)), + _ => None, + } +} + +/// The inline image parameters that name a colour space resource (§8.9.7). +fn inline_space(dict: &Dict) -> Option<(&'static [u8], Vec)> { + [&b"CS"[..], b"ColorSpace"].into_iter().find_map(|key| { + let n = dict.get(key)?.as_name()?; + (!DEVICE_SPACES.contains(&n)).then(|| (key, n.to_vec())) + }) +} + +/// Checks the form content can stand inside the page's own: nothing skipped, no `Q` without its +/// `q`, no text object or marked content left open in a way that would swallow the page's +/// following operators. Returns what must be closed at the end (`ET`, `EMC`s, `Q`s), in order. +fn closing_ops(ops: &[Op]) -> Result, EditError> { + if ops.len() > MAX_FORM_OPS { + return Err(invalid("the group has too many operations to ungroup")); + } + let mut saves = 0usize; + let mut marked = 0usize; + let mut text = false; + for op in ops { + match op.op.as_slice() { + b"q" => saves = saves.saturating_add(1), + b"Q" => { + saves = saves + .checked_sub(1) + .ok_or_else(|| invalid("the group restores more graphics states than it saves; it cannot be ungrouped exactly"))? + } + b"BT" if text => return Err(invalid("the group starts a text object inside another; it cannot be ungrouped exactly")), + b"BT" => text = true, + b"ET" if !text => return Err(invalid("the group ends a text object it did not start; it cannot be ungrouped exactly")), + b"ET" => text = false, + b"BMC" | b"BDC" => marked = marked.saturating_add(1), + b"EMC" => { + marked = + marked.checked_sub(1).ok_or_else(|| invalid("the group ends marked content it did not start; it cannot be ungrouped exactly"))? + } + b"Do" if text => return Err(invalid("the group draws an object inside a text object; it cannot be ungrouped exactly")), + _ => {} + } + if saves > MAX_NESTING || marked > MAX_NESTING { + return Err(invalid("the group's content is nested too deeply to ungroup")); + } + } + // A form's end closes all of these implicitly; inside the page they must be closed explicitly + // so the page's own operators after the `Do` keep their state. + let mut out = Vec::new(); + if text { + out.push("ET"); + } + out.extend(std::iter::repeat_n("EMC", marked)); + out.extend(std::iter::repeat_n("Q", saves)); + Ok(out) +} + +/// The form content with clashing resource names changed, other bytes as they were. +fn renamed_content(data: &[u8], ops: &[Op], renames: &Renames) -> Vec { + if renames.is_empty() { + return data.to_vec(); + } + let rename = |category: &'static [u8], name: &[u8]| renames.get(&(category, name.to_vec())).cloned(); + let mut edits = Vec::new(); + for op in ops { + let mut changed = op.clone(); + let mut any = false; + if let Some((category, index)) = resource_operand(op) + && let Some(fresh) = op.name(index).and_then(|n| rename(category, n)) + && let Some(slot) = changed.operands.get_mut(index) + { + *slot = Object::Name(fresh); + any = true; + } + if let Some((dict, _)) = changed.inline.as_mut() + && let Some((key, name)) = inline_space(dict) + && let Some(fresh) = rename(b"ColorSpace", &name) + { + dict.set(key.to_vec(), Object::Name(fresh)); + any = true; + } + if any { + let mut bytes = Vec::new(); + write_op(&changed, &mut bytes); + while bytes.last().is_some_and(u8::is_ascii_whitespace) { + bytes.pop(); + } + edits.push((op.span.clone(), bytes)); + } + } + Pieces::join(&[data]).splice(edits).into_iter().next().unwrap_or_default() +} + +fn numbers(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +fn number(v: f64) -> Vec { + let mut out = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(v), &mut out); + out +} + +/// Ungroup Form artwork `index` (from [`crate::page_images`]) on `page`: its content replaces +/// the `Do` that draws it. Refuses images, forms drawn inside other forms and forms whose +/// content would look or mean something else on the page. +pub fn ungroup_form(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let images = crate::images::page_images(doc, page)?; + let image = images.get(index).ok_or_else(|| EditError::Invalid(format!("page {} has no object {}", page + 1, index + 1)))?; + if !image.is_form { + return Err(invalid("only grouped Form artwork can be ungrouped; this is an image")); + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + let mut resources = resolved_dict(doc, p.dict.get(b"Resources")).unwrap_or_default(); + let xobjects = resolved_dict(doc, resources.get(b"XObject")).unwrap_or_default(); + let form_obj = xobjects.get(image.name.as_bytes()).cloned().ok_or_else(|| invalid("the group is no longer in the page's resources"))?; + let resolved = doc.resolve(&form_obj); + let Object::Stream(form) = &*resolved else { + return Err(invalid("the group is not a stream")); + }; + if form.dict.name(b"Subtype") != Some(b"Form") { + return Err(invalid("only grouped Form artwork can be ungrouped")); + } + if form.dict.contains(b"StructParent") || form.dict.contains(b"StructParents") { + return Err(invalid("the group is part of the document's accessibility tags; ungrouping it would break them")); + } + let bbox = numbers(doc, form.dict.get(b"BBox")) + .and_then(|b| <[f64; 4]>::try_from(b).ok()) + .filter(|b| b.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) + .ok_or_else(|| invalid("the group has no valid bounding box"))?; + let matrix = match form.dict.get(b"Matrix") { + None => Matrix::IDENTITY, + Some(m) => numbers(doc, Some(m)) + .and_then(|v| <[f64; 6]>::try_from(v).ok()) + .filter(|v| v.iter().all(|x| x.is_finite() && x.abs() <= 1e12)) + .map(Matrix) + .ok_or_else(|| invalid("the group has an invalid matrix"))?, + }; + // A form without resources uses those of the content that draws it (PDF 1.1 files). + let form_resources = resolved_dict(doc, form.dict.get(b"Resources")).unwrap_or_else(|| resources.clone()); + let defaults = |res: &Dict| { + let spaces = resolved_dict(doc, res.get(b"ColorSpace")).unwrap_or_default(); + [&b"DefaultGray"[..], b"DefaultRGB", b"DefaultCMYK"].map(|k| spaces.get(k).map(|v| doc.resolve(v))) + }; + if defaults(&form_resources) != defaults(&resources) { + return Err(invalid("the group uses other default colour spaces than the page; ungrouping it would change its colours")); + } + let data = form.decoded_within(MAX_FORM_BYTES).map_err(|_| invalid("the group's content cannot be decoded within its size limit"))?; + let parsed = parse(&data); + if parsed.skipped > 0 { + return Err(invalid("the group's content has damaged operators; ungrouping it could change the page")); + } + let closing = closing_ops(&parsed.ops)?; + + // Locate the `Do` among all of the page's streams, as the image edits do. + let p_streams = crate::images::content_streams(doc, &p.dict); + let mut offset = 0usize; + for (_, d) in p_streams.iter().take(image.stream) { + offset = offset.checked_add(d.len()).and_then(|n| n.checked_add(1)).ok_or_else(|| invalid("the page content is too large"))?; + } + let own = p_streams.get(image.stream).ok_or_else(|| invalid("the group's page stream is missing"))?; + let do_op = parse(&own.1).ops.get(image.op).cloned().ok_or_else(|| invalid("the group's drawing operation is missing"))?; + let span = offset.checked_add(do_op.span.start).ok_or_else(|| invalid("the page content is too large"))?; + let pieces = Pieces::join(&p_streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let page_ops = pieces.parse().ops; + let target = page_ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| invalid("the group's drawing operation could not be located"))?; + // A `Do` inside a text object is invalid (§9.4) and some viewers still draw it; inlining a + // form's own text objects there would nest them. + let in_text = page_ops.iter().take(target).fold(false, |text, op| { + if op.is("BT") { + true + } else if op.is("ET") { + false + } else { + text + } + }); + if in_text { + return Err(invalid("the group is drawn inside a text object; it cannot be ungrouped exactly")); + } + let page_states = resolved_dict(doc, resources.get(b"ExtGState")).unwrap_or_default(); + if let Some(group) = resolved_dict(doc, form.dict.get(b"Group")) + && group_matters(doc, &group, &form_resources, &p.dict, &page_ops, target, &page_states) + { + return Err(invalid("the group is a transparency group whose blending would change if its content were painted directly; it stays grouped")); + } + + let renames = merge_resources(doc, &mut resources, &form_resources)?; + let mut properties = None; + if let Some(oc) = form.dict.get(b"OC") { + // Optional content on the form: the inlined content is marked with the same membership. + let mut props = resolved_dict(doc, resources.get(b"Properties")).unwrap_or_default(); + let existing = props.iter().find(|(_, v)| same_resource(doc, v, oc)).map(|(n, _)| n.clone()); + let name = match existing { + Some(n) => n, + None => { + let fresh = (1..=MAX_RENAME_TRIES) + .map(|k| format!("PCOC{k}").into_bytes()) + .find(|n| !props.contains(n)) + .ok_or_else(|| invalid("the group's optional content cannot be named on the page"))?; + props.set(fresh.clone(), oc.clone()); + fresh + } + }; + resources.set(b"Properties".to_vec(), Object::Dict(props)); + properties = Some(name); + } + + let content = renamed_content(&data, &parsed.ops, &renames); + let mut inline = b"q\n".to_vec(); + if matrix != Matrix::IDENTITY { + for v in matrix.0 { + inline.extend(number(v)); + inline.push(b' '); + } + inline.extend_from_slice(b"cm\n"); + } + let [x0, y0, x1, y1] = [bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3])]; + for v in [x0, y0, x1 - x0, y1 - y0] { + inline.extend(number(v)); + inline.push(b' '); + } + inline.extend_from_slice(b"re W n\n"); + if let Some(name) = &properties { + let mut bdc = Vec::new(); + write_op(&Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())]), &mut bdc); + inline.extend(bdc); + inline.push(b'\n'); + } + inline.extend(content); + inline.push(b'\n'); + for op in closing { + inline.extend_from_slice(op.as_bytes()); + inline.push(b'\n'); + } + if properties.is_some() { + inline.extend_from_slice(b"EMC\n"); + } + inline.push(b'Q'); + + let changed = + pieces.splice([(page_ops.get(target).map(|o| o.span.clone()).ok_or_else(|| invalid("the group's drawing operation is missing"))?, inline)]); + let contents = crate::text::rewritten_contents(doc, &p_streams, changed); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + })?; + Ok(()) +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7de0287634c..74567eb4881 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } pdfcraft-measure = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } pdfcraft-render = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b46d88acf1a..3d2ba30820a 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Crop pages", "crop", "page.crop", Ready), item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Reverse pages", "arrow-up-down", "page.reverse", Ready), item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b28758e2414..fcd56efee4d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"), c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"), c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2db..b797bc8d835 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -6,7 +6,7 @@ //! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it //! off, scripts don't run and AF calls keep working natively. -use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -343,19 +343,24 @@ impl Session { let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); let mut edits = Vec::with_capacity(found.len()); for (page, c, turn) in found { - // A turned field keeps its place: the rectangle is already the one on the page. - let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; - edits.push(Edit::AddField { - page, - rect: c.rect, - kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, - pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, - }, - name: Some(c.name.clone()), - }); - if let Some(props) = props { + let (kind, comb) = match c.kind { + pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None), + pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), + pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), + }; + let mut props = FieldProps::default(); + if turn != 0 { + // A turned field keeps its place: the rectangle is already the one on the page. + props.rotation = Some((0, turn)); + props.rect = Some((0, c.rect)); + } + if let Some(cells) = comb { + props.max_len = Some(Some(cells)); + props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; + } + edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); + if turn != 0 || comb.is_some() { edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3432cd868e1..518eb215069 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,7 +36,6 @@ pub mod signature_image; pub mod signature_scan; pub mod text_fonts; mod text_preview; -mod upright_image; pub mod word_count; pub mod xfa; @@ -81,6 +80,8 @@ pub enum ImageEdit { }, /// Show only this box of it (user space), the pixels staying where they are. Crop([f64; 4]), + /// Grouped Form artwork only: put its content on the page as separate objects (Ungroup). + Ungroup, /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. Replace { name: String, @@ -306,6 +307,10 @@ struct Editor { saved: Arc>, /// Passwords that open [`Self::saved`]. saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + unsaved_file: bool, undo: Vec, redo: Vec, keys: Keys, @@ -317,15 +322,15 @@ struct Editor { text_blocks: TextBlocksCache, } -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they -/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long -/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit, -/// undo and save (each reopens the working file), so a stale entry never matches. +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. #[derive(Default)] struct TextBlocksCache(std::sync::Mutex>); /// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>); +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); impl Clone for TextBlocksCache { fn clone(&self) -> Self { @@ -337,20 +342,16 @@ impl Clone for TextBlocksCache { impl Editor { /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. fn text_blocks(&self, page: usize) -> Result>, EditError> { - let source = (!self.cos.is_modified()).then(|| self.cos.source_identity()); - if let Some(source) = &source - && let Ok(cache) = self.text_blocks.0.lock() - && let Some((s, p, blocks)) = cache.as_ref() - && s == source + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() && *p == page + && state.matches(&self.cos) { return Ok(blocks.clone()); } let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Some(source) = source - && let Ok(mut cache) = self.text_blocks.0.lock() - { - *cache = Some((source, page, blocks.clone())); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); } Ok(blocks) } @@ -984,6 +985,27 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + /// Render `doc` with the layer visibility in its `info.layers`. fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); @@ -1247,6 +1269,10 @@ pub enum Edit { pages: Vec, to: usize, }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, InsertBlankPage { at: usize, width: f64, @@ -1777,6 +1803,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete pages", "Move page", "Move pages", + "Reverse pages", "Insert blank page", "Duplicate page", "Duplicate pages", @@ -1916,6 +1943,7 @@ impl Edit { Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), @@ -2004,6 +2032,7 @@ impl Edit { ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", + ImageEdit::Ungroup => "Ungroup artwork", }), Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), @@ -2088,6 +2117,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Edit::RotatePages { .. } | Edit::DeletePages { .. } | Edit::MovePages { .. } + | Edit::ReversePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } @@ -2298,6 +2328,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, Edit::InsertBlankPage { at, width, height } => { pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } @@ -2518,6 +2549,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Ungroup => return Ok(pdfcraft_edit::ungroup_form(doc, *page, *index)?), }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; } @@ -3526,6 +3558,7 @@ impl Session { cos, saved, saved_keys: keys.clone(), + unsaved_file: false, undo: Vec::new(), redo: Vec::new(), keys, @@ -3823,7 +3856,7 @@ impl Session { /// file is clean; undo past a later save is not, because the restored snapshot's own base /// is an earlier revision. fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); } /// Use the current state's password for viewing (protection edits change it). @@ -4108,6 +4141,7 @@ impl Session { pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; editor.saved = bytes; editor.saved_keys = keys; + editor.unsaved_file = false; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -4171,7 +4205,7 @@ impl Session { editor.undo.clear(); editor.redo.clear(); Self::adopt_keys(doc); - doc.dirty = false; + Self::sync_dirty(doc); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } @@ -4332,12 +4366,18 @@ impl Session { }; return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); } - let cos = match doc.editor.as_ref() { + let mut cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } @@ -4645,6 +4685,9 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } // Unsaved work with no edit yet still needs its first recovery snapshot. d.generation += 1; } @@ -4697,6 +4740,9 @@ impl Session { if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.path = path; d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } d.generation += 1; d.snapshot_generation = d.generation; // its bytes are already in the recovery store } @@ -4906,7 +4952,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 5dd9aff5b58..4902d72f534 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError { Empty, TooLong, Hidden, - UrlOrNetwork, + /// A URL, a network or device path, or a path that climbs above its root. + NotLocal(craft_docpath::Refused), MissingSourcePath, /// The target doesn't end in `.pdf`. NotPdf, @@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError { Self::Empty => f.write_str("the file specification is empty"), Self::TooLong => f.write_str("the file specification is too long"), Self::Hidden => f.write_str("the file specification contains hidden or control characters"), - Self::UrlOrNetwork => f.write_str("only local document paths are supported"), + Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"), Self::MissingSourcePath => f.write_str("a relative link needs a source document path"), Self::NotPdf => f.write_str("the linked file is not a .pdf file"), Self::NotAFile => f.write_str("the linked path is not a regular file"), @@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { } /// Resolve a PDF remote-Go-To file specification to a local path without opening it. -/// Relative paths are based on the source document; an in-memory source has no fallback to CWD. +/// Relative paths are based on the source document, which should be an absolute path; an +/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are +/// refused ([`craft_docpath::resolve`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< if raw.chars().any(hides_text) { return Err(RemoteDocumentPathError::Hidden); } - if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") { - return Err(RemoteDocumentPathError::UrlOrNetwork); - } - let path = Path::new(raw); - let drive_absolute = remote_document_path_is_absolute(raw); + // A GoToR file specification is a path, not a URL: any scheme of two or more characters is + // refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included. if let Some((scheme, _)) = raw.split_once(':') { - let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + let is_scheme = scheme.len() > 1 + && scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); - let windows_drive = scheme.len() == 1 && drive_absolute; - if valid_scheme && !windows_drive { - return Err(RemoteDocumentPathError::UrlOrNetwork); + if is_scheme { + return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - if drive_absolute { - return Ok(path.to_path_buf()); + let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; + // A relative result would be read against the working directory. + if !path.is_absolute() { + return Err(RemoteDocumentPathError::MissingSourcePath); } - let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?; - let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?; - Ok(parent.join(path)) + Ok(path) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -503,23 +503,56 @@ mod tests { use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; use std::path::Path; - let source = Path::new("/docs/chapter/source.pdf"); - assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf"))); + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + let folder = source.parent().unwrap(); + assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf"))); + assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path())); + #[cfg(unix)] assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); + assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); assert_eq!(resolve(Some(source), ""), Err(E::Empty)); assert_eq!(resolve(Some(source), " "), Err(E::Empty)); - for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}"); - } - for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"] + for url in + ["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}"); + assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); } assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); } + #[test] + fn remote_document_paths_to_other_computers_and_devices_are_refused() { + use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; + + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + // Refused before any filesystem access: none of these exist, and a stat of a network path + // would contact its host. + for not_local in [ + "//server/share/a.pdf", + "\\\\server\\share\\a.pdf", + "\\\\h\\s\\a.pdf", + "\\\\?\\UNC\\h\\s\\a.pdf", + "\\??\\UNC\\h\\s\\a.pdf", + "/??/x", + "\\??\\C:\\a.pdf", + "\\\\.\\device\\a.pdf", + "/\\server/share/a.pdf", + "NUL.pdf", + ] { + assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}"); + } + // Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share. + #[cfg(unix)] + { + let doubled = format!("/{}", source.with_file_name("a.pdf").display()); + assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}"); + } + } + #[test] fn urls_follow_the_artcraft_scheme() { assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec305b1ca5a..43712d8e999 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use super::*; +mod ungroup; + /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; @@ -1987,6 +1989,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() { assert_eq!(page_texts(&s, id), ["Page 1"]); } +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -3638,6 +3693,34 @@ fn detecting_fields_on_a_scanned_form() { } } +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -5801,3 +5884,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + 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(&[6, 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 a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); +} + +/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are +/// still read once per document state, and undo and redo never get another state's blocks. +#[test] +fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { + let (mut session, id) = session_with(1); + let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + let before = blocks(&session); + assert_eq!(before[0].editor_text, "Page 1"); + let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() }; + session.apply(id, edit).unwrap(); + let edited = blocks(&session); + assert_eq!(edited[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once"); + session.undo(id).unwrap(); + assert_eq!(blocks(&session)[0].editor_text, "Page 1"); + session.redo(id).unwrap(); + let redone = blocks(&session); + assert_eq!(redone[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&redone, &blocks(&session))); +} + +/// A new document, or one restored from recovery, has never been written where it belongs: undo +/// back to the bytes it was opened from must not make it clean, or closing it would discard the +/// whole document without asking. Saving makes it clean; Revert of a saved one too. +#[test] +fn undo_never_makes_an_unsaved_document_clean() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap(); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved"); + assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery"); + s.revert(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere"); + + let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.mark_recovered(recovered, Some("Report.pdf".into())); + s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(recovered).unwrap(); + assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved"); + + let saved = s.save_bytes(new).unwrap(); + s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap(); + assert!(!s.get(new).unwrap().dirty); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); +} diff --git a/crates/engine/src/tests/ungroup.rs b/crates/engine/src/tests/ungroup.rs new file mode 100644 index 00000000000..82cd7b902c0 --- /dev/null +++ b/crates/engine/src/tests/ungroup.rs @@ -0,0 +1,110 @@ +//! Ungroup (Edit a PDF): grouped Form artwork becomes page content without changing the page. + +use std::sync::Arc; + +use super::render_page; +use crate::{DocId, Edit, ImageEdit, Session}; + +fn pdf(objs: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// A page drawn the way CAD exports draw it: one Form XObject, scaled onto the page, holding the +/// text, line work and a nested symbol. Its resource names clash with the page's, it clips its +/// content to a box smaller than what it draws, and it leaves a graphics state open. +fn cad_page() -> (Session, DocId) { + let page = "q 0.12 0 0 0.12 0.5 0.5 cm /Fm1 Do Q BT /F1 9 Tf 10 10 Td (Page footer) Tj ET 0 0 1 RG 2 w 5 290 m 195 290 l S"; + let form = "0 w 0.03 0 0 RG q 4 3 m 1600 3 l 1600 2400 l 4 2400 l h W n 6 w 0 0 0 RG \ + 100 100 m 1500 100 l 1500 2300 l S /G1 gs 200 1800 m 1400 1800 l S \ + BT 60 0 0 60 160 2000 Tm /F1 1.4 Tf (Elektroschema Deckblatt) Tj ET \ + 1 0.5 0 rg 300 600 400 300 re f \ + q 1 0 0 1 900 600 cm /Sym Do Q \ + 1700 2500 m 1900 2500 l S q 0 0 1 rg"; + let symbol = "0 0 0 RG 8 w 0 0 m 200 200 l S 0 200 m 200 0 l S BT /F1 40 Tf 0 220 Td (K1) Tj ET"; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> /ExtGState << /G1 << /LW 0.5 >> >> >> /Contents 4 0 R >>".to_string(), + stream("", page), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 1650 2450] /Matrix [1 0 0 1 10 20] /Resources << /Font << /F1 7 0 R >> /ExtGState << /G1 << /LW 20 >> >> /XObject << /Sym 8 0 R >> >>", + form, + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".to_string(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 7 0 R >> >>", symbol), + ]; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("cad.pdf", None, Arc::new(pdf(&objs)), None).expect("opens"); + (s, id) +} + +/// Ungrouping a page-sized group leaves every pixel as it was, makes its paragraphs and its nested +/// symbol separate objects, survives save and reopen, and is one undo step. +#[test] +fn ungrouping_cad_artwork_keeps_every_pixel_and_frees_its_objects() { + let (mut s, id) = cad_page(); + let before = render_page(&s, id); + let objects = s.get(id).unwrap().editable_objects(0).unwrap(); + let kinds: Vec<&str> = objects.iter().map(|o| o.content_type).collect(); + assert_eq!(kinds, ["text", "form"], "the page's footer and one page-sized group: {objects:?}"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).unwrap(); + let after = render_page(&s, id); + assert_eq!((after.width, after.height), (before.width, before.height)); + assert!(after.rgba == before.rgba, "ungrouping changed the page's pixels"); + + let doc = s.get(id).unwrap(); + let texts: Vec = doc.text_blocks(0).into_iter().map(|b| b.text).collect(); + assert!(texts.iter().any(|t| t == "Elektroschema Deckblatt"), "{texts:?}"); + assert!(texts.iter().any(|t| t == "Page footer"), "{texts:?}"); + let images = doc.page_images(0); + assert_eq!(images.len(), 1, "the nested symbol is now a group of its own: {images:?}"); + assert!(images[0].is_form); + + // Saved and reopened, it still looks the same. + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert!(render_page(&s, reopened).rgba == before.rgba, "the saved file looks different"); + + assert_eq!(s.undo(id).unwrap().to_string(), "Ungroup artwork"); + assert_eq!(s.get(id).unwrap().page_images(0).len(), 1); + assert!(s.get(id).unwrap().page_images(0)[0].rect[2] > 150.0, "the page-sized group is back"); + assert!(render_page(&s, id).rgba == before.rgba); + + // The nested symbol can be ungrouped in turn, still without changing a pixel. + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).unwrap(); + assert!(s.get(id).unwrap().page_images(0).is_empty()); + assert!(s.get(id).unwrap().text_blocks(0).iter().any(|b| b.text == "K1")); + assert!(render_page(&s, id).rgba == before.rgba); +} + +/// A refused ungroup changes nothing and leaves no undo step. +#[test] +fn a_refused_ungroup_changes_nothing() { + let (mut s, id) = cad_page(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).unwrap(); + let bytes = s.get(id).unwrap().bytes.clone(); + // No groups are left: index 0 is no longer a form. + assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Ungroup }).is_err()); + assert_eq!(s.get(id).unwrap().bytes, bytes); + assert_eq!(s.undo(id).unwrap().to_string(), "Ungroup artwork"); +} diff --git a/crates/filters/README.md b/crates/filters/README.md index de7f8d22306..df64e4b7c0e 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -24,7 +24,7 @@ encode_flate(data) -> Vec // zlib, level 6 - `chain` is applied first to last, as in a `/Filter` array. - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. -- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. +- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs index 5aa6443f69b..dac0851e07a 100644 --- a/crates/filters/src/flate.rs +++ b/crates/filters/src/flate.rs @@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step { Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())), Some(_) => match inflate_raw(&data[2..], max) { // A header that only looked valid: retry the whole input as raw deflate. - Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f), + Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) { + // Keep the original corruption diagnostic only if the retry is corrupt + // too. A limit error must remain fatal even for tolerant decoding. + Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f), + retry => retry, + }, r => r, }, None => inflate_raw(data, max), diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index ffaf08ca11f..9a0124d859f 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -295,6 +295,26 @@ mod tests { assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100); } + #[test] + fn raw_flate_fallback_preserves_output_limits() { + // RFC 1951 stored blocks: the first is nonfinal with LEN=257 and + // NLEN=!257, followed by a final empty block. Its ignored alignment + // bits make the first two bytes also look like a valid zlib header. + let data = vec![b'A'; 257]; + let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe]; + raw.extend_from_slice(&data); + raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]); + let chain = [(Filter::Flate, Params::default())]; + for max in [0, 1, 128, 256] { + assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + } + for max in [257, 258] { + assert_eq!(decode(&chain, &raw, max).unwrap(), data); + assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false)); + } + } + #[test] fn tolerant_flate_returns_partial() { let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect(); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0cc456cba62..bef0fb8be45 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,8 +1,8 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact -//! published metrics ([`crate::Std14`]). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded +//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]). //! //! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph //! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own @@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { } } +/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded +/// program's own advances, so measure with them too; a standard-14 guess from the name can be +/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up +/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself +/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { + use skrifa::instance::{LocationRef, Size}; + use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem == 0.0 { + continue; + } + let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default()); + let (charmap, cmap) = (face.charmap(), face.cmap().ok()); + let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF); + m.widths = (0..=255u32) + .map(|code| { + let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c))) + .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o)))); + // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest. + match glyph { + None if m.subset => 0.0, + _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem), + } + }) + .collect(); + m.first = 0; + return; + } +} + /// Replace an existing cmap directory entry with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { @@ -607,6 +645,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + if m.std14.is_some() { + embedded_widths(doc, font, &mut m); + } let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -1160,14 +1201,42 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } } } - if let Some(Object::Stream(s)) = font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() - && let Ok(data) = s.decoded() - { - parse_to_unicode(&data, &mut out); + match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { + Some(Object::Stream(s)) => { + if let Ok(data) = s.decoded() { + parse_to_unicode(&data, &mut out); + } + } + // `/ToUnicode /Identity-H` is not a stream, as ISO 32000-2 §9.10.3 requires, but CAD + // exporters (WSCAD among them) write it for composite fonts whose two-byte codes are the + // UTF-16 code units. Acrobat, MuPDF and pdf.js read it as code = Unicode, and so does + // the renderer's text layer; without it the text could be seen but not edited or found. + Some(Object::Name(n)) if composite && matches!(n.as_slice(), b"Identity-H" | b"Identity-V") => { + parse_to_unicode(&identity_to_unicode(), &mut out); + } + _ => {} } out } +/// A ToUnicode CMap mapping each two-byte code to the same UTF-16 code unit, leaving out the +/// surrogates (which are not characters on their own) and the C0 controls. One `bfrange` per +/// high byte, so no range crosses a last-byte boundary (§9.10.3). +fn identity_to_unicode() -> Vec { + let highs: Vec = (0u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).collect(); + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange\n"); + // At most 100 entries per section (§9.7.5.2 limits for CMap files). + for chunk in highs.chunks(100) { + cmap.push_str(&format!("{} beginbfrange\n", chunk.len())); + for h in chunk { + let low = if *h == 0 { 0x20 } else { 0 }; + cmap.push_str(&format!("<{h:02X}{low:02X}> <{h:02X}FF> <{h:02X}{low:02X}>\n")); + } + cmap.push_str("endbfrange\n"); + } + cmap.into_bytes() +} + #[cfg(test)] mod tests { use pdfcraft_cos::{Dict, Document, Object, Stream}; @@ -1218,6 +1287,37 @@ mod tests { assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); } + /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name + /// ("Mono" would mean Courier, 600 units for every glyph). + #[test] + fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); + } + #[test] fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; @@ -1384,6 +1484,37 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose + /// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls + /// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. + #[test] + fn identity_to_unicode_names_read_codes_as_unicode() { + let doc = Document::new_empty(); + for name in ["Identity-H", "Identity-V"] { + let f = font( + &mut Document::new_empty(), + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("RobotoMono-Regular")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), + ("ToUnicode", Object::name(name)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); + assert_eq!(m.text_of(0x0001), None); + assert_eq!(m.text_of(0xD800), None); + assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); + } + let simple = font( + &mut Document::new_empty(), + vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], + ); + let m = Metrics::from_dict(&doc, &simple); + assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); + } + /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is /// still in the font, but nothing is when the font gives no default width to point at. #[test] diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index dbee788e3b7..d5bf073b1eb 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -1,6 +1,6 @@ //! Prepare a form ▸ automatic field detection: find where a printed form expects answers — -//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new -//! field from the label next to it (Acrobat's auto-naming). +//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares — +//! and name each new field from the label next to it (Acrobat's auto-naming). //! //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). @@ -30,6 +30,11 @@ pub struct Shapes { pub enum Kind { Text, CheckBox, + /// A row of touching cells, one character each: a text field with the comb flag and + /// `cells` as its character limit. + Comb { + cells: usize, + }, } #[derive(Clone, Debug, PartialEq)] @@ -40,6 +45,10 @@ pub struct Candidate { pub name: String, } +/// The most points a subpath may have and still be read as a rectangle: its four corners plus +/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). +const MAX_RECT_POINTS: usize = 16; + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -65,12 +74,33 @@ impl DetectionPath { return None; } let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; - let mut points = self.points.as_slice(); + let mut points = self.points.clone(); let repeated_end = points.len() > 1 && near(points[0], *points.last()?); if repeated_end { - points = &points[..points.len() - 1]; + points.pop(); + } + if !(self.closed || implicitly_closed || repeated_end) { + return None; } - if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + // A point on a straight side, between its neighbours, doesn't change the shape: drop it, + // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A + // point that turns back along the side is not between its neighbours and stays, so a + // path that doubles back is still not a rectangle. + let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6; + let mut i = 0; + while points.len() > 4 && i < points.len() { + let n = points.len(); + let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break }; + let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]); + let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]); + if on_row || on_column { + points.remove(i); + i = 0; + } else { + i += 1; + } + } + if points.len() != 4 { return None; } let mut horizontal = [false; 4]; @@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let (bx, by) = ctm.apply(x, y); let b = [bx, by]; if !path.complex { - if path.points.len() >= 5 { + if path.points.len() > MAX_RECT_POINTS { path.complex = true; } else { if path.points.is_empty() { @@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) } +/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is +/// enough: dates split into day and month groups of two cells. Cells are square or taller +/// than wide; an empty table of wider cells is not a comb. +fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + let cells: Vec<[f64; 4]> = boxes + .iter() + .copied() + .filter(|r| { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 + }) + .collect(); + // `b` comes right after `a` in a row: same size, its left edge on a's right edge. + let follows = |a: &[f64; 4], b: &[f64; 4]| { + (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5 + }; + let mut out = Vec::new(); + for first in &cells { + if cells.iter().any(|a| follows(a, first)) { + continue; + } + let (mut last, mut n) = (first, 1usize); + // Each step moves at least 4 pt right, so this ends; the bound is for safety. + while let Some(b) = cells.iter().find(|b| follows(last, b)) + && n < cells.len() + { + last = b; + n += 1; + } + if n >= 2 { + out.push(([first[0], first[1], last[2], first[3]], n)); + } + } + out +} + /// Propose fields for a page. `existing` are the page's widget rectangles (no candidate /// overlaps them); names are unique against `taken`. pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec { @@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S let l = label_for(&labels, rect, false); push(Kind::Text, rect, l, &mut out); } + // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before + // check boxes so the cells don't each become one. + for (rect, cells) in combs(&shapes.boxes) { + if labels.iter().any(|x| overlaps(x.rect, rect)) { + continue; + } + let l = label_for(&labels, rect, false); + push(Kind::Comb { cells }, rect, l, &mut out); + } // Small squares: check boxes, labelled by the text to their right. for r in &shapes.boxes { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S for c in &mut out { let base = if c.name.is_empty() { match c.kind { - Kind::Text => "Text".to_string(), + Kind::Text | Kind::Comb { .. } => "Text".to_string(), Kind::CheckBox => "Check Box".to_string(), } } else { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ef545cd4336..60066e47397 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() { assert!(again.is_empty(), "{again:?}"); } +#[test] +fn detection_makes_a_row_of_touching_cells_one_comb_field() { + use crate::detect::*; + let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }; + let words = vec![ + w("Postcode", 40.0, 205.0, 85.0), + w("A", 88.0, 205.0, 95.0), + w("Postcode", 40.0, 105.0, 85.0), + w("B", 88.0, 105.0, 95.0), + w("Yes", 70.0, 41.0, 90.0), + ]; + let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); + // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn + // right to left, and a lone square. + let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); + boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); + boxes.push([50.0, 40.0, 62.0, 52.0]); + let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); + let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); + assert_eq!( + got, + [ + ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), + ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), + ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), + ], + "{found:?}" + ); + // Squares with a gap between them stay check boxes, and so do touching squares of + // different sizes. + let apart = Shapes { + boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], + rules: Vec::new(), + }; + let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); + // An empty table of cells wider than tall (an attendance sheet) is not a comb. + let grid = |cw: f64, ch: f64| { + (0..3).flat_map(move |r| { + (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) + }) + }; + for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { + let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); + assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); + } +} + #[test] fn page_shapes_reads_boxes_and_rules() { let mut doc = fixture(); @@ -2023,6 +2071,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() { } } +/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's +/// letter cells), or on every side, is the rectangle it looks like. A point that turns back +/// along a side is not on the way between its neighbours, so such a path is still not a box. +#[test] +fn page_shapes_reads_rectangles_with_points_along_their_sides() { + for content in [ + "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", + "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", + "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", + ] { + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); + } + for content in [ + // Out past the corner and back: a spike, not a side. + "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", + // A sixth point off the sides. + "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index 8d56fa4aa44..affa4ab1631 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -215,7 +215,10 @@ fn num(v: f64) -> String { /// WinAnsiEncoding), each word stretched to its box so selection and search highlight the /// right place. Marked content `/OCR` so it can be told apart from the page's own text. pub fn text_layer(words: &[PlacedWord]) -> Vec { - let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); + // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they + // would stay set for every later content stream on the page, so text added after OCR (a + // header or footer) drew invisibly (#3029). + let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); for w in words { let width = helvetica_width(&w.text, 1.0); let height = w.up[0].hypot(w.up[1]); @@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec { out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text))); out.extend_from_slice(b" Tj\n"); } - out.extend_from_slice(b"ET\nEMC\n"); + out.extend_from_slice(b"ET\nQ\nEMC\n"); out } @@ -272,6 +275,26 @@ mod tests { assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n")); } + /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator + /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn + /// after it on the page starts from the graphics state it had before. + #[test] + fn the_layer_restores_the_graphics_state_it_changes() { + let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; + let s = String::from_utf8(text_layer(&[w])).unwrap(); + let mut depth = 0_i32; + for token in s.split_ascii_whitespace() { + match token { + "q" => depth += 1, + "Q" => depth -= 1, + "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"), + _ => {} + } + assert!(depth >= 0, "unbalanced Q in {s:?}"); + } + assert_eq!(depth, 0, "the layer leaves a q open: {s:?}"); + } + /// A word box is tight around the ink, so the text's size and baseline come from the letters: /// capitals, lowercase without ascenders, descenders. #[test] diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abecb..133bc03ecfc 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } +/// Reverse the order of the pages at `indices` among the positions they occupy; every other +/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. +pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let mut all = walk(doc)?; + check(indices, all.len())?; + let mut slots = indices.to_vec(); + slots.sort_unstable(); + slots.dedup(); + if slots.len() < 2 { + return Err(OrganizeError::Invalid("reversing needs at least two pages".into())); + } + let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect(); + for (slot, page) in slots.iter().zip(pages.into_iter().rev()) { + if let Some(entry) = all.get_mut(*slot) { + *entry = page; + } + } + rebuild(doc, &all) +} + /// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing /// fonts and images with the originals. pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431da..c857cd4da77 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -244,6 +244,25 @@ fn move_pages_reorders() { assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test] +fn reverse_pages_reorders() { + let mut doc = open(fixture()); + reverse_pages(&mut doc, &[0, 1, 2]).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]); + // Unsorted, repeated indices name the same slots; page 1's slot stays put. + reverse_pages(&mut doc, &[2, 1, 2]).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]); +} + +#[test] +fn cannot_reverse_missing_or_fewer_than_two_pages() { + let mut doc = open(fixture()); + assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3))); + assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_)))); + assert!(!doc.is_modified(), "failed operations leave the document untouched"); +} + #[test] fn inserts_blank_page() { let mut doc = open(fixture()); diff --git a/crates/redact/README.md b/crates/redact/README.md index 3b2d54e814c..7cd5ded1e46 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); -- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a +- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream. @@ -44,6 +44,11 @@ This limits appearance generation; it is not a limit on the input PDF or the who Structure cleanup resolves named MCIDs and indirect content references. Form content references are updated to the cleaned copy; unchanged sibling descriptions remain. +Replacement descriptions are removed only from marked-content spans changed by cleanup. +Unchanged spans retain their descriptions; unused property entries are retired during redaction +and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still +used by surviving pages or forms are kept. + A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c76e1199536..52ca6c276f2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -66,12 +66,16 @@ pub(crate) struct Output { /// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a /// form has none of its own): [`unlink_doomed`] removes those entries. pub doomed: Vec, + /// Original property values retired by this scope or a nested form. Other resource holders + /// may still name them; unlink only entries no surviving content uses. + pub retired_properties: Vec, /// Glyphs / inline images still overlapping a region (verify mode). pub residue: usize, /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, + pub error: Option, } pub(crate) struct Scope<'a> { @@ -246,9 +250,10 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// An entry goes unless a page that draws through that dictionary still draws the name (the same /// image shown on another page, or again outside a mark). A dictionary that loses an entry is /// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Fails closed when a page's use of a name cannot be told. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> { - if doomed.is_empty() { +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); @@ -278,11 +283,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { - let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; let mut xo = res_dict(doc, &res, b"XObject"); let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() { + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { continue; } let mut contents = Vec::with_capacity(users.len()); @@ -310,11 +320,84 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } if pruned { - let mut res = res; res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; } } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions remain reachable too, even after every page holder is cleaned. + unlink_form_properties(doc, &holder_nodes, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } Ok(()) } @@ -341,6 +424,71 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -380,6 +528,12 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + // A rewritten piece loses its comments, so keep where they began and ended: a comment that + // ran on into the next piece still hides that piece's first line. + let comment_edges = joined.comment_edges(&parsed); + // Layer removal also changes enclosing replacement spans, so remember the operators before + // any cleanup, not just the survivors of the layer pass. + let original = parsed.ops.clone(); if !scope.hidden_layers.is_empty() { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -405,6 +559,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< } } + let mut processed = Vec::new(); for (piece, mut changed) in pieces.into_iter().zip(rewrite) { let mut ops: Vec = Vec::with_capacity(piece.len()); for op in piece { @@ -622,11 +777,68 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< if !path.is_empty() { ops.append(&mut path); } - out.streams.push(changed.then(|| serialize_ops(&ops))); + processed.push((ops, changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { + out.streams.push(changed.then(|| { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(&ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes + })); } out } +fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { + let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); + let unchanged: HashSet = + processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect(); + let mut stack = Vec::new(); + let mut affected = HashSet::new(); + for op in original { + match op.op.as_slice() { + b"BDC" | b"BMC" => stack.push(op.span.start), + b"EMC" => { + stack.pop(); + } + _ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()), + _ => {} + } + } + let properties = res_dict(doc, resources, b"Properties"); + for (ops, changed) in processed { + for op in ops { + if !op.is("BDC") || !affected.contains(&op.span.start) { + continue; + } + let property = match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)), + Some(p) => Some(doc.resolve(p)), + None => None, + }; + if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) { + let mut stripped = false; + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + stripped |= dict.remove(key).is_some(); + } + if stripped && let Some(property) = op.operands.get_mut(1) { + *property = Object::Dict(dict); + *changed = true; + report.replacement_text += 1; + } + } + } + } +} + /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` /// items with the glyphs under a region replaced by displacements, and how many were removed. fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { @@ -798,7 +1010,9 @@ fn xobject( out.residue += inner.residue; out.vertical |= inner.vertical; out.doomed.extend_from_slice(&inner.doomed); + out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); + out.error = inner.error.or(out.error.take()); if scope.mode == Mode::Verify { return None; } @@ -808,19 +1022,35 @@ fn xobject( } let mut dict = s.dict.clone(); dict.remove(b"Length"); - // Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a - // `Do` the copy no longer makes stops keeping the original image or form reachable. + let mut cleaned_res = res.clone(); + // Install rewritten nested forms before finding live property names: a new form may + // still inherit its caller's resources, and its new Do name must resolve here. if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { - let mut res = res.clone(); - let mut xo = res_dict(doc, &res, b"XObject"); - if let Some(d) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(d)); + let mut xo = res_dict(doc, &cleaned_res, b"XObject"); + if let Some(data) = &new_data { + prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); } - for (n, r) in &inner.xobjects { - xo.set(n.clone(), Object::Ref(*r)); + for (name, reference) in &inner.xobjects { + xo.set(name.clone(), Object::Ref(*reference)); } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - dict.set(b"Resources".to_vec(), Object::Dict(res)); + cleaned_res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + if let Some(data) = &new_data { + let content = std::slice::from_ref(data); + match prune_properties(doc, &mut cleaned_res, content) { + Ok(retired) => out.retired_properties.extend(retired), + Err(error) => { + out.error = Some(error); + return None; + } + } + if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } + } + if cleaned_res != res { + dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); } let (changed, empty) = tags::touched(doc, &res, &data, new_data.as_deref().unwrap_or(&data)); let stream = match new_data { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 61231bd982a..a8dbf64d9ba 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -119,6 +119,7 @@ pub struct Report { pub forms_removed: usize, pub annotations: usize, pub fields: usize, + pub replacement_text: usize, /// Structure elements that lost alternate/actual text or emptied marked content. pub tags: usize, /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field @@ -334,6 +335,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed_xobjects: Vec = Vec::new(); + let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); @@ -353,10 +355,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result) -> Result = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } let kept: Vec = annots .into_iter() .filter(|a| { @@ -487,7 +530,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result Re let mut total = 0; // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); + let mut retired_properties = Vec::new(); let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -165,12 +166,16 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } scope.hidden_layers = off.clone(); let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); + if let Some(error) = out.error { + return Err(error.into()); + } let blocks = scope.layer_blocks; total += out.residue + blocks + report.glyphs; if !write { continue; } doomed.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); let mut new_list = list.clone(); let mut changed = false; for (i, new) in out.streams.into_iter().enumerate() { @@ -184,24 +189,24 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re changed = true; } if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + // Pin inherited resources before replacing them. When no page still inherits the + // original holder, retiring it also retires descriptions no longer used anywhere. + crate::localize_resources(doc, &[pi])?; + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; let mut res = resources; if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed form or image bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); crate::interp::prune(&mut xo, &out.replaced, &page_content); for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } res.set(b"XObject".to_vec(), Object::Dict(xo)); } + retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -210,7 +215,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed)?; + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; Ok(total) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc412..a4e3530aba7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a +/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to +/// lie past a mark that covers all of it on screen, and was kept. +#[test] +fn a_whole_line_under_a_mark_goes_even_without_widths() { + let text = b"illicit little lilies"; + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), + font.as_bytes().to_vec(), + b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), + stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), + ]); + // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. + let line = [5.0, 95.0, 90.0, 112.0]; + mark(&mut doc, 0, &[line], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); + assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. @@ -490,6 +514,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } +#[test] +fn replies_to_a_redacted_comment_go_with_it() { + // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of + // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); + // A classic table, so the objects' strings are plain in the file. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); +} + +/// The replies listed before the annotations they answer, deepest first, and a pop-up on a +/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of +/// #2697). +#[test] +fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left = annots_of(&doc, &p.dict); + assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); +} + #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); @@ -1056,6 +1133,100 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() { } } +#[test] +fn replacement_text_and_property_resources_do_not_survive_redaction() { + let mut doc = one_page( + b"/Span /P0 BDC /Span <> BDC BT /F1 10 Tf 10 200 Td (SECRET)", + "/Properties << /P0 7 0 R >>", + vec![ + b"<< /MCID 0 /ActualText (LEAK_PROPERTY) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) >>".to_vec(), + stream("", b"Tj ET EMC EMC /Span <> DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(ObjRef::new(8, 0))])); + }) + .unwrap(); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(content(&saved, 0).contains("Public")); + let p = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn inherited_form_properties_lose_replacement_text() { + let mut doc = one_page( + b"/Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC BT /F1 10 Tf 10 100 Td (Public) Tj ET", + ), + b"<< /ActualText (LEAK_FORM_PROPERTY) >>".to_vec(), + ], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn properties_used_by_unmodified_inheriting_forms_stay() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET /Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/Span /P0 BDC 0 0 10 10 re f EMC"), b"<< /ActualText (Public) >>".to_vec()], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"P0").unwrap()); + assert_eq!(property.as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public"); +} + +#[test] +fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!( + "/Span <> BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC" + ); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /Public 7 0 R >>", + vec![b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec()], + ); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + let dict = property.as_dict().unwrap(); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description removed: {key:?}, hidden={hidden}"); + } + } +} + #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped @@ -1117,3 +1288,125 @@ fn masks_and_alternates_outside_redaction_stay() { assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates")); assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM"); } + +#[test] +fn stream_boundary_comment_stays_inert_after_unrelated_redaction() { + for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]); + let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec()))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let reopened = reopen(&doc); + let page = &pdfcraft_model::pages(&reopened)[0]; + let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap(); + // Independent of Pieces::join: the renderer concatenates streams with spaces. + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable"); + let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE"))); + assert_eq!(live, tail.contains(&b'\n')); + } +} + +#[test] +fn rewriting_a_piece_keeps_its_leading_comment_terminator() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut doc = one_page(b"q Q % note", "", vec![]); + let mut tail = eol.to_vec(); + tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET"); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let p = &pdfcraft_model::pages(&doc)[0]; + let (_, data) = page_streams(&doc, &p.dict, 0).unwrap(); + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed"); + assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP")))); + } +} + +#[test] +fn named_hidden_text_descriptions_are_removed_from_saved_bytes() { + let mut doc = one_page( + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC", + "/Properties << /P0 7 0 R >>", + vec![b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec()], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { + for hidden in [false, true] { + for inherited in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec(), + b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + let first = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = doc.add(first.get(b"Resources").unwrap().clone()); + let second_content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Fm Do".to_vec()))); + let mut second = first; + second.set(b"Contents".to_vec(), Object::Ref(second_content)); + if inherited { + second.remove(b"Resources"); + } else { + second.set(b"Resources".to_vec(), Object::Ref(resources)); + } + let second = doc.add(Object::Dict(second)); + doc.update_dict(ObjRef::new(3, 0), |d| { + if inherited { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + if inherited { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page_index in 0..2 { + assert_eq!(under(&mut saved, page_index, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page = pdfcraft_model::pages(&saved).swap_remove(page_index); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + assert!(!properties.as_dict().unwrap().contains(b"P0")); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(property.as_dict().unwrap().contains(key), "shared form lost {key:?}: hidden={hidden}, inherited={inherited}"); + } + } + } + } +} + +mod property_retirement; diff --git a/crates/redact/src/tests/property_retirement.rs b/crates/redact/src/tests/property_retirement.rs new file mode 100644 index 00000000000..ad5197637a8 --- /dev/null +++ b/crates/redact/src/tests/property_retirement.rs @@ -0,0 +1,212 @@ +use super::*; + +const SECRET: &[u8] = b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC"; +const DESCRIPTION: &[u8] = b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>"; +const PUBLIC: &[u8] = b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>"; + +fn resources(doc: &Document, page: usize) -> Dict { + let page = pdfcraft_model::pages(doc).swap_remove(page); + doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone() +} + +fn property(doc: &Document, resources: &Dict, name: &[u8]) -> Option { + let properties = doc.resolve(resources.get(b"Properties")?); + doc.resolve(properties.as_dict()?.get(name)?).as_dict().cloned() +} + +fn assert_description(dict: &Dict) { + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description lost {key:?}"); + } +} + +fn add_second_page(doc: &mut Document, data: &[u8], resources: Object) -> ObjRef { + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + page.set(b"Resources".to_vec(), resources); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + second +} + +fn redact_first(doc: &mut Document) -> Result { + mark(doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(doc, Some(&[0])) +} + +#[test] +fn surviving_form_own_resources_do_not_retain_unused_shared_descriptions() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + // The page and its unchanged Form both reference the same resource object. The + // dictionary names the Form itself, but the Form's content never invokes that name. + let shared = doc.add(Object::Dict(resources(&doc, 0))); + for owner in [ObjRef::new(3, 0), ObjRef::new(9, 0)] { + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + redact_first(&mut doc).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page_resources = resources(&saved, 0); + let xobjects = saved.resolve(page_resources.get(b"XObject").unwrap()); + let form = saved.resolve(xobjects.as_dict().unwrap().get(b"Fm").unwrap()); + let form_resources = saved.resolve(form.as_dict().unwrap().get(b"Resources").unwrap()); + let form_resources = form_resources.as_dict().unwrap(); + assert!(property(&saved, form_resources, b"P0").is_none()); + assert_description(&property(&saved, form_resources, b"Public").unwrap()); + } +} + +#[test] +fn unused_property_aliases_are_retired_by_value_not_resource_name() { + for direct in [false, true] { + let value = if direct { std::str::from_utf8(DESCRIPTION).unwrap() } else { "7 0 R" }; + let mut doc = one_page(SECRET, &format!("/Properties << /P0 {value} >>"), vec![DESCRIPTION.to_vec()]); + let mut second_resources = resources(&doc, 0); + let original_properties = doc.resolve(second_resources.get(b"Properties").unwrap()); + let mut alias = Dict::new(); + alias.set(b"Alias".to_vec(), original_properties.as_dict().unwrap().get(b"P0").unwrap().clone()); + second_resources.set(b"Properties".to_vec(), Object::Dict(alias)); + add_second_page(&mut doc, b"q Q", Object::Dict(second_resources)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 1), b"Alias").is_none(), "direct={direct}"); + } +} + +#[test] +fn unrelated_unused_property_with_the_same_name_is_not_changed() { + let mut doc = one_page(SECRET, "/Properties << /P0 7 0 R >>", vec![DESCRIPTION.to_vec(), PUBLIC.to_vec()]); + let mut second_resources = resources(&doc, 0); + let mut properties = Dict::new(); + properties.set(b"P0".to_vec(), Object::Ref(ObjRef::new(8, 0))); + second_resources.set(b"Properties".to_vec(), Object::Dict(properties)); + let second = add_second_page(&mut doc, b"q Q", Object::Dict(second_resources.clone())); + let before = doc.get(second); + redact_first(&mut doc).unwrap(); + assert_eq!(doc.get(second), before, "an unrelated page was rewritten"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); +} + +#[test] +fn null_resources_forms_preserve_a_retired_property_still_used_by_nested_dp() { + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Outer 8 0 R /Inner 9 0 R >>", + vec![ + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120] /Resources null", b"/Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /P0 DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + add_second_page(&mut doc, b"/Outer Do", Object::Ref(shared)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 0), b"P0").is_none()); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); + assert_eq!(under(&mut saved, 1, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn uninspectable_forms_with_candidate_properties_fail_closed() { + for own in [false, true] { + for malformed in [false, true] { + let filter = if malformed { "" } else { "/Filter /UnsupportedPropertyTest" }; + let data = if malformed { b")".as_slice() } else { b"opaque" }; + if malformed { + assert!(pdfcraft_content::parse(data).skipped > 0, "fixture must need parser recovery"); + } + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Fm 8 0 R >>", + vec![DESCRIPTION.to_vec(), stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 120] {filter}"), data)], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + if own { + doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + add_second_page(&mut doc, b"/Fm Do", Object::Ref(shared)); + assert_eq!(under(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]]), 6); + let error = redact_first(&mut doc).unwrap_err(); + assert!(matches!(error, RedactError::Cos(_)), "own Resources={own}, malformed={malformed}: {error:?}"); + } + } +} + +#[test] +fn hidden_text_in_nested_forms_retires_named_descriptions_and_keeps_public_text() { + let mut doc = one_page( + b"/Outer Do", + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Outer 9 0 R /Inner 10 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Inner Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC", + ), + ], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + // The rewritten inner Form owns the surviving /Public description after resources are + // localized; no page-level property lookup is sufficient to check it. + assert!(saved.scan_objects().any(|(_, object)| { + object.as_dict().and_then(|d| d.get(b"ActualText")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "PUBLIC_ACTUAL") + })); +} + +#[test] +fn removing_hidden_layer_content_cleans_its_surviving_outer_description() { + let mut doc = one_page( + b"/Span /P0 BDC /OC /Off BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC EMC \ + /Span /Public BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC", + "/Properties << /P0 7 0 R /Public 8 0 R /Off 9 0 R >>", + vec![DESCRIPTION.to_vec(), PUBLIC.to_vec(), b"<< /Type /OCG /Name (Hidden test layer) >>".to_vec()], + ); + let mut defaults = Dict::new(); + defaults.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + let mut optional_content = Dict::new(); + optional_content.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + optional_content.set(b"D".to_vec(), Object::Dict(defaults)); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"OCProperties".to_vec(), Object::Dict(optional_content))).unwrap(); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenLayers]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 0), b"Public").unwrap()); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 8d5686dd8cc..b2127894fd8 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4586,7 +4586,11 @@ trailer << /Root 1 0 R >> let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer"); + // Only a guard against a hang: the shared budget is checked by the warnings below, which + // come out the same on every machine. The render decodes and reads 512 MiB of content, + // a third of a second on a desktop, and a busy FreeBSD VM in CI ran past the minute this + // used to allow. + let page = rx.recv_timeout(std::time::Duration::from_secs(600)).expect("a self-painting form must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable"); assert_eq!( @@ -4657,30 +4661,73 @@ trailer << /Root 1 0 R >> /// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array /// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold) /// 30 times, which took 38 seconds through the hash map. + /// + /// The build each paint does is timed against reading the function's samples, the part of it + /// no table can skip, not against a deadline: through the hash map it took about a hundred + /// times as long as the read, through the flat array about as long. A deadline can't tell + /// them apart on every machine: the hash map's 30 paints took 14 s on one desktop, the flat + /// array's over 10 s on a busy FreeBSD VM in CI. No counter does either (both read every + /// sample on every build), so the test compares two times taken on this thread, the fastest + /// of several rounds of each: a stall from other work (other tests, swapping) would have to + /// slow every build and spare a read to fail it. #[test] fn shadings_with_large_sampled_functions_paint_quickly() { + use hayro::hayro_interpret::Function; + use hayro_syntax::bit_reader::BitReader; + use hayro_syntax::object::{FromBytes, Object}; + use std::time::{Duration, Instant}; + const SAMPLES: usize = 1 << 21; let samples = zlib_zeros(SAMPLES); + let function = format!( + "<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >>", + samples.len() + ); // Black ramp on the left half, 30 times over; a red square after it. let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 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 << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); pdf.extend_from_slice(b"5 0 obj << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Function 6 0 R >> endobj\n"); - pdf.extend_from_slice( - format!("6 0 obj << /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >> stream\n", samples.len()) - .as_bytes(), - ); + pdf.extend_from_slice(format!("6 0 obj {function} stream\n").as_bytes()); pdf.extend_from_slice(&samples); pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(render_first_page(pdf)); - }); - let page = - rx.recv_timeout(std::time::Duration::from_secs(10)).expect("painting a large sampled function must not rebuild a hash map each time"); + let page = render_first_page(pdf); assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn"); assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip"); assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws"); + + // What each paint does: build the function from its stream. Timed against reading the + // stream's samples, the part of the build no table can skip. + let mut bytes = format!("{function} stream\n").into_bytes(); + bytes.extend_from_slice(&samples); + bytes.extend_from_slice(b"\nendstream"); + let object = Object::from_bytes(&bytes).expect("a stream object"); + let Object::Stream(stream) = &object else { panic!("the function is a stream") }; + let (mut build, mut read) = (Duration::MAX, Duration::MAX); + for _ in 0..8 { + let started = Instant::now(); + let function = Function::new(&object); + assert!(function.is_some(), "a sampled function"); + drop(function); + build = build.min(started.elapsed()); + + let started = Instant::now(); + let decoded = stream.decoded().expect("the samples decode"); + let mut reader = BitReader::new(&decoded); + let mut values = Vec::new(); + while values.len() < SAMPLES + && let Some(value) = reader.read(8) + { + values.push(value); + } + assert_eq!(std::hint::black_box(&values).len(), SAMPLES); + drop(values); + read = read.min(started.elapsed()); + } + assert!( + build < read * 10, + "building a {SAMPLES}-sample function took {build:?}, over ten times the {read:?} reading its samples takes (a hash map took a hundred times)" + ); } /// Patch 31's flat table gives every sampled function the values the hash map gave: points @@ -6186,6 +6233,8 @@ endstream endobj /// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to /// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator /// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds. + /// The runs are counted (patch 34) rather than timed: a deadline also failed when a slow, + /// busy CI machine took longer than it allowed, and the count is the same on every machine. #[test] fn spot_colour_images_run_their_tint_transform_once_per_sample_value() { const SIDE: usize = 2048; @@ -6202,11 +6251,11 @@ endstream endobj let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); let pdf = image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(render_first_page(pdf)); - }); - let out = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a spot-colour image must not run its tint transform for every pixel"); + // The count is per thread, and this render runs on this one. + let before = hayro::hayro_interpret::color::tint_transform_runs(); + let out = render_first_page(pdf); + let runs = hayro::hayro_interpret::color::tint_transform_runs() - before; + assert_eq!(runs, 256, "the tint transform runs once per possible sample value, not once for each of the {} pixels", SIDE * SIDE); let w = out.width as usize; assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0"); assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1"); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b767e283145..d6aede4471c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu dense } -/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs -/// belongs to the span. -/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs -/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062). +/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Bidi { + L, + R, + Num, + N, +} + +fn bidi_class(text: &str) -> Bidi { + if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) { + Bidi::R + } else if text.chars().any(char::is_numeric) { + Bidi::Num + } else if text.chars().any(char::is_alphabetic) { + Bidi::L + } else { + Bidi::N + } +} + +/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the +/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has +/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals +/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { - let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); + // Glyphs and word gaps (None) in visual order. + let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); + for (w, &i) in idx.iter().enumerate() { + if w > 0 && spaces.get(w).copied().unwrap_or(false) { + items.push((None, Bidi::N)); + } + items.push((Some(i), bidi_class(&glyphs[i].text))); + } + let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); + let text = |k: usize| texts.get(k).copied().unwrap_or(""); + let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + let n = items.len(); + // W4: a single separator between two digits belongs to the number. + for k in 1..n.saturating_sub(1) { + let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}"); + if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num { + items[k].1 = Bidi::Num; + } + } + // W5: terminators (percent, currency) next to a number belong to it. + let term = + |s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}')); + for k in 1..n { + if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + for k in (0..n.saturating_sub(1)).rev() { + if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + // W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right. + let mut strong = para; + for it in &mut items { + match it.1 { + Bidi::L | Bidi::R => strong = it.1, + Bidi::Num if strong == Bidi::L => it.1 = Bidi::L, + _ => {} + } + } + // N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's. + let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R }; let mut k = 0; - while k < idx.len() { - if rtl(idx[k]) { + while k < n { + if items[k].1 != Bidi::N { + k += 1; + continue; + } + let mut e = k; + while e < n && items[e].1 == Bidi::N { + e += 1; + } + let before = if k == 0 { para } else { dir(items[k - 1].1) }; + let after = if e == n { para } else { dir(items[e].1) }; + let resolved = if before == after { before } else { para }; + for it in &mut items[k..e] { + it.1 = resolved; + } + k = e; + } + // Levels (I1/I2), then L2: reverse every run at or above each level, highest first. + let rtl_para = para == Bidi::R; + let levels: Vec = items + .iter() + .map(|it| match it.1 { + Bidi::R => 1, + Bidi::Num => 2, + _ if rtl_para => 2, + _ => 0, + }) + .collect(); + let mut order: Vec = (0..n).collect(); + for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() { + let mut k = 0; + while k < n { + if levels[order[k]] < level { + k += 1; + continue; + } let mut e = k; - while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) { + while e < n && levels[order[e]] >= level { e += 1; } - idx[k..=e].reverse(); - if let Some(gaps) = spaces.get_mut(k + 1..=e) { - gaps.reverse(); + order[k..e].reverse(); + k = e; + } + } + let mut j = 0; + let mut gap = false; + for &o in &order { + match items[o].0 { + None => gap = true, + Some(i) => { + if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) { + *slot = i; + *space = gap && j > 0; + } + gap = false; + j += 1; } - k = e + 1; - } else { - k += 1; } } } @@ -1574,6 +1682,37 @@ mod tests { } } + /// WSCAD writes `/ToUnicode /Identity-H`, a name rather than a CMap stream, on Type 0 fonts + /// whose two-byte codes are UTF-16 code units. The codes read as that Unicode text (as in + /// Acrobat), so the page's text can be selected and found; before, it had none. + #[test] + fn identity_to_unicode_names_give_text() { + use lopdf::{Document, Object, Stream, dictionary}; + for name in ["Identity-H", "Identity-V"] { + let mut doc = Document::with_version("1.4"); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "RobotoMono-Regular", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 600, "CIDToGIDMap" => "Identity" + }); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "RobotoMono-Regular", "Encoding" => "Identity-H", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => name }); + let operators = + "BT /F1 12 Tf 1 0 0 1 20 250 Tm <004400650063006B0062006C006100740074> Tj 1 0 0 1 20 200 Tm <005A00FC0072006900630068002020AC> Tj ET"; + let content = doc.add_object(Stream::new(dictionary! {}, operators.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "Deckblatt\nZürich €", "{name}"); + assert_eq!(text.find("zürich"), vec![9..15], "{name}: glyphs 9 to 14"); + } + } + /// One page per text matrix, each showing `codes` in Helvetica whose `/ToUnicode` maps the /// codes in `unicode` (a `bfchar` body) to several characters. fn ligature_pages(unicode: &str, codes: &str, matrices: &[&str]) -> Pdf { @@ -2092,6 +2231,26 @@ mod tests { assert_eq!(layout(v).plain_text(), "שלום עולם"); } + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. + #[test] + fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { + let line = |words: &[&str]| { + let mut v = Vec::new(); + let mut x = 10.0; + for w in words { + word(&mut v, w, x, 10.0, 6.0); + x += w.chars().count() as f32 * 6.0 + 6.0; + } + layout(v).plain_text() + }; + // Each line as drawn, left to right. + assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים"); + assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה."); + assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب"); + assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם"); + assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price"); + } + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. #[test] fn words_and_lines_around_a_glyph() { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aa9fe54f667..42e9474f070 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a088..9c2ee5026b5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -423,6 +423,8 @@ pub struct DocView { /// This frame's consumers, with visible cells ahead of prefetch. thumb_demand: HashMap, frame_queue: Vec, + /// A page-list edit after layout invalidates this frame's scales and page indexes. + frame_geometry_changed: bool, /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport /// bounds them, and the byte and count limits apply to prefetch only. frame_visible: HashSet, @@ -452,6 +454,9 @@ pub struct DocView { last_press_time: f64, last_press_pos: Option, last_queue: Vec, + /// Finished renders wait in the pool instead of reaching the view (tests): what the view + /// shows before a raster arrives can be looked at however fast the renderer is. + pub hold_rasters: bool, viewport_w: f32, viewport_h: f32, page_count: usize, @@ -497,6 +502,9 @@ pub struct DocView { pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, pub image_selection: Option, + /// Edit text & images: the last press on grouped artwork (time, position, image), so two + /// presses in quick succession ungroup it even when egui reports the second as a drag start. + pub(crate) group_press: Option<(f64, Pos2, usize)>, /// Select tool: the picture clicked, to copy in its original format. pub picked_image: Option, /// A paragraph box being dragged (moved, or resized from its right edge) in Edit text. @@ -577,6 +585,8 @@ pub struct DocView { pub(crate) last_scroll: Vec2, /// Side-by-side compare: scroll to here this frame, following the other half. pub(crate) sync_scroll: Option, + /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. + page_widget: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -713,6 +723,7 @@ impl DocView { thumbs: HashMap::new(), thumb_demand: HashMap::new(), frame_queue: Vec::new(), + frame_geometry_changed: false, frame_visible: HashSet::new(), stale_thumbs: HashSet::new(), stale_tiles: HashSet::new(), @@ -729,6 +740,7 @@ impl DocView { last_press_time: 0.0, last_press_pos: None, last_queue: Vec::new(), + hold_rasters: false, viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), @@ -754,6 +766,7 @@ impl DocView { edit_images: HashMap::new(), objects: Default::default(), image_selection: None, + group_press: None, picked_image: None, block_drag: None, pending_action: None, @@ -770,6 +783,7 @@ impl DocView { sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, sync_scroll: None, + page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -798,6 +812,7 @@ impl DocView { /// adopt the new page geometry, keeping the reader's place where possible. pub fn document_changed(&mut self, info: &DocInfo) { self.invalidate_content(); + self.frame_geometry_changed = true; self.page_count = info.pages.len(); self.page_heights = info.pages.iter().map(|p| p.height).collect(); let last = self.page_count.saturating_sub(1); @@ -1513,6 +1528,7 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.frame_geometry_changed = false; self.pages_panel = None; self.panel_gap = None; self.thumb_demand.clear(); @@ -1728,12 +1744,23 @@ impl DocView { } pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) { + if self.frame_geometry_changed { + // Dialogs and edits can replace pages after the canvas collected demand. Its + // scales and indexes belong to the old pages, not to the new renderer (#3149). + self.begin_render_frame(); + self.last_queue.clear(); + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); if queue != self.last_queue { pool.set_queue(queue.clone()); self.last_queue = queue; } - self.receive(ctx, pool); + if !self.hold_rasters { + self.receive(ctx, pool); + } if !self.last_queue.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(30)); } @@ -1792,6 +1819,12 @@ impl DocView { self.errors.insert(r.request.page, e); return 0; } + // Even current demand may have been sized with obsolete geometry or a different + // device limit. Check before cache eviction, conversion and every upload path. + let max_side = ctx.input(|i| i.max_texture_side); + if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side { + return 0; + } // Remember heavy pages: only these get the zoom/pan settle holds, so fast pages // never pay added latency. Stale entries die with the tiles on edits. if r.millis >= SLOW_MILLIS { @@ -2552,6 +2585,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if app.quick_tool == QuickTool::Hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } if let Some(v) = view.sync_scroll.take() { scroll = scroll.scroll_offset(v); } @@ -2697,14 +2749,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { view.selection_drag = false; } - // The Hand tool pans: the content widget takes every drag, so scroll by its delta. + // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.scroll_with_delta(resp.drag_delta()); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); @@ -3822,6 +3874,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } if let Some((name, w)) = field_props { app.open_field_props(&name, w); } @@ -5519,6 +5575,81 @@ trailer << /Root 1 0 R >> } } + /// #3149: the replacement dialog commits after the canvas collected this frame's demand. + #[test] + fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 2048); + let mut app = PdfCraftApp::new(); + let original = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap(); + app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap(); + let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() }; + app.views[0].frame_queue = vec![request]; + app.views[0].frame_visible.insert(0); + assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] })); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0)); + let edited_bytes = doc.bytes.clone(); + let pool = RenderPool::new_inline(doc.display.clone(), Default::default()); + app.views[0].finish_render_frame(&ctx, &doc.info, &pool); + assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer"); + + // The next layout can render a bounded backdrop and sharp tiles at the same zoom. + let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request }; + let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request }; + for _ in 0..2 { + let view = &mut app.views[0]; + view.begin_render_frame(); + view.frame_visible.insert(0); + view.frame_queue = vec![backdrop, tile]; + view.finish_render_frame(&ctx, &doc.info, &pool); + } + let view = &app.views[0]; + assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]); + assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]); + assert!(view.errors.is_empty()); + assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit"); + assert!(doc.dirty); + assert_eq!(doc.can_undo(), Some("Replace page")); + } + + #[test] + fn oversized_raster_results_do_not_replace_cached_textures() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 64); + for request in [ + RenderRequest::default(), + RenderRequest { tag: THUMB_TAG, ..Default::default() }, + RenderRequest { tag: GRID_TAG, ..Default::default() }, + RenderRequest { tag: PRINT_TAG, ..Default::default() }, + RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() }, + ] { + let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default()); + view.last_queue = vec![request]; + let texture = |v: &DocView| { + v.pages + .values() + .map(|p| &p.tex) + .chain(v.thumbs.values().map(|p| &p.tex)) + .chain(v.grid_pages.values().map(|(_, t)| t)) + .chain(v.print_pages.values().map(|(_, t)| t)) + .chain(v.tiles.values()) + .map(|t| (t.id(), t.size())) + .collect::>() + }; + assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4); + let cached = texture(&view); + assert_eq!(cached.len(), 1); + for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] { + assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}"); + assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster"); + } + assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4); + assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress"); + } + } + #[test] fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() { let ctx = egui::Context::default(); @@ -5580,6 +5711,30 @@ trailer << /Root 1 0 R >> assert_eq!(v.pages.get(&0).unwrap().tag, STALE_TAG); } + /// #1858: a released drawing stays painted from its points until a raster that has it + /// arrives. A page raster that lands before the drawing's edit is applied (in full screen the + /// edit waits for the next frame, and a render already under way can finish first) is of the + /// document without it, and must not end the preview. + #[test] + fn a_raster_from_before_a_drawing_is_committed_keeps_it_drawn() { + let ctx = egui::Context::default(); + let info = thumbnail_info(1); + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + let req = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() }; + v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]); + v.last_queue = vec![req]; + assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0, "the raster is taken"); + assert_eq!(v.comments.settling_len(), 1, "a raster from before the commit doesn't have the stroke"); + v.comments.drawings_committed(); + v.last_queue = vec![req]; + assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0); + assert_eq!(v.comments.settling_len(), 0, "a raster after the commit draws it"); + // An edit that fails leaves nothing to wait for. + v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]); + v.comments.drawings_refused(); + assert_eq!(v.comments.settling_len(), 0, "a refused drawing isn't left painted"); + } + #[test] fn page_and_tile_admission_accounts_for_bytes_before_count_limits() { let mut info = thumbnail_info(40); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index e3609fcc9c9..832ce7cfbd4 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -629,6 +629,12 @@ impl PdfCraftApp { "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } "ocr.enhance_camera" => { // The corners start on the document's edges when they can be found, else // inset on the current page (camera_ui::page_input). diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5a708c14f62..68b1a72a0c6 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -420,6 +420,10 @@ pub(crate) struct Settling { pub points: Vec<[f64; 2]>, /// Frame time (seconds) at release; a raster that never arrives must not leave a ghost. pub since: f64, + /// Its annotation is in the document, so a raster of the page from now on draws it. Until + /// then a raster is of the document without it (in full screen the edit is applied the frame + /// after the release, and a raster already on its way can arrive in between). + pub committed: bool, } /// Longest a released drawing stays painted without its page raster, and how many are kept. @@ -428,7 +432,7 @@ const SETTLE_MAX: usize = 16; impl CommentView { /// Remember a released drag drawing so it stays on screen until its page is re-rendered. - fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { + pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) { return; } @@ -436,7 +440,7 @@ impl CommentView { if self.settling.len() >= SETTLE_MAX { self.settling.remove(0); } - self.settling.push(Settling { page, tool, points: points.to_vec(), since: now }); + self.settling.push(Settling { page, tool, points: points.to_vec(), since: now, committed: false }); } /// How many released drawings are still painted from their points (waiting for a raster). @@ -444,9 +448,22 @@ impl CommentView { self.settling.len() } - /// A fresh raster of `page` is on screen: it draws the committed annotation itself. + /// A fresh raster of `page` is on screen: it draws the committed annotations itself. One + /// still waiting for its edit isn't in that raster, and stays drawn from its points. pub(crate) fn page_rendered(&mut self, page: usize) { - self.settling.retain(|s| s.page != page); + self.settling.retain(|s| s.page != page || !s.committed); + } + + /// The edit adding the released drawings was applied: rasters from now on draw them. + pub(crate) fn drawings_committed(&mut self) { + for s in &mut self.settling { + s.committed = true; + } + } + + /// The edit adding the released drawings failed: nothing will draw them. + pub(crate) fn drawings_refused(&mut self) { + self.settling.retain(|s| s.committed); } } @@ -487,6 +504,9 @@ pub struct CommentView { pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). pub default_request: Option<(usize, usize)>, + /// A file attachment comment's file was asked for: (page, index, open it in a new tab + /// rather than save it). + pub attachment_request: Option<(usize, usize, bool)>, /// Replace Text: the struck-out text's quads while the replacement is typed. pub replace: Option>, /// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file. @@ -1578,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's +/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading +/// the file changes nothing, so a read-only document offers them too. +pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) { + let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index }; + let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return }; + if ui.button(tl!("Save attachment…")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, false)); + ui.close(); + } + if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, true)); + ui.close(); + } + ui.separator(); +} + /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; @@ -1585,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); + attachment_items(ui, view, info, a); if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce36..2ac1853f4ec 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -268,6 +268,7 @@ fn card( t: &Tokens, a: &Annotation, thread: &[&Annotation], + info: &DocInfo, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, @@ -447,6 +448,7 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); + crate::comments::attachment_items(ui, view, info, a); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 742641330c4..630d66190c2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -793,14 +793,27 @@ struct Limits { max_unauthenticated: usize, /// How long a write may block on a client that never reads its replies. write_timeout: std::time::Duration, + /// How long a connection refused after a reply is kept half-open, discarding input, so the + /// reply is not lost to a reset (see [`close_after_reply`]). + linger: std::time::Duration, + /// How many refused connections may linger at once. A lingering connection has given back + /// its place among [`Self::max_unauthenticated`], so this is what bounds their threads; + /// beyond it a refused connection is closed at once. + max_lingering: usize, } #[cfg(not(target_arch = "wasm32"))] -const LIMITS: Limits = - Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) }; - -/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the -/// connection authenticates, and on every way its thread can end. +const LIMITS: Limits = Limits { + auth_deadline: std::time::Duration::from_secs(10), + max_unauthenticated: 8, + write_timeout: std::time::Duration::from_secs(30), + linger: std::time::Duration::from_secs(1), + max_lingering: 8, +}; + +/// One of a limited number of places, given back when dropped: a place among +/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused, +/// and on every way its thread can end), or among [`Limits::max_lingering`]. #[cfg(not(target_arch = "wasm32"))] struct Slot(std::sync::Arc); @@ -850,6 +863,38 @@ impl std::io::Read for Reading { } } +/// Close a connection whose last reply has just been written without losing that reply. +/// +/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and +/// the reset can discard the reply before the client reads it (Linux does): a client still +/// sending when it is refused would never learn why. So send FIN first, then read and discard +/// whatever arrives until the client closes its side or `linger` passes, and only then close. +/// +/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places, +/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one +/// the connection is closed at once after the FIN, accepting the risk of a reset rather than +/// holding another thread. +#[cfg(not(target_arch = "wasm32"))] +fn close_after_reply(reading: &mut Reading, place: Option, linger: std::time::Duration) { + use std::io::Read; + let _ = reading.stream.shutdown(std::net::Shutdown::Write); + let Some(_place) = place else { return }; + reading.until = Some(std::time::Instant::now() + linger); + let mut sink = [0u8; 1024]; + // Each read is bounded by the time left, and none is made once `linger` has passed. + while matches!(reading.read(&mut sink), Ok(n) if n > 0) {} +} + +/// A connection is being refused before `auth`: give back its place among those waiting to +/// authenticate, so it does not keep a real client out while it lingers, and take a place to +/// linger in if one is free. Done before the reply is written, so a client that has read the +/// reply sees the places as they will be. +#[cfg(not(target_arch = "wasm32"))] +fn refuse(slot: &mut Option, lingering: &std::sync::Arc, limits: Limits) -> Option { + drop(slot.take()); + Slot::take(lingering, limits.max_lingering) +} + /// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`. /// /// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be @@ -868,6 +913,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result std::io::Result { let secs = limits.auth_deadline.as_secs_f64(); let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } // End of stream or a failed read ends the connection, as before. Ok(LineRead::End) | Err(_) => break, Ok(LineRead::TooLong) => { let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } } @@ -945,7 +998,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect(); + for s in &others { + // A refused one has already been closed, so its write may fail; its reply says why. + let _ = writeln!(s.try_clone().unwrap(), "{auth}"); + let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply"); + assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}"); + } + assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed()); + drop(refused); + } + + /// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still + /// gets its reply, and is then closed at once instead of lingering. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn only_so_many_refused_connections_linger() { + use std::io::{BufReader, Write}; + // A linger far longer than the checks below take, even on a loaded machine. + let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve_with(client, limits).unwrap(); + let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; + // The first refused connection takes the only lingering place and keeps it. + let first = connect(ep.port); + writeln!(first.try_clone().unwrap(), "{bad}").unwrap(); + let mut r1 = BufReader::new(first.try_clone().unwrap()); + assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001); + // The second still gets its reason (it has nothing unread, so no reset)... + let second = connect(ep.port); + let mut w2 = second.try_clone().unwrap(); + writeln!(w2, "{bad}").unwrap(); + let mut r2 = BufReader::new(second.try_clone().unwrap()); + assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001); + // ...and is closed outright, not lingering: writing to it soon fails, where a lingering + // connection would read and discard for the whole of `linger`. + let started = std::time::Instant::now(); + let closed = loop { + if w2.write_all(b"x").is_err() { + break true; + } + if started.elapsed() > limits.linger { + break false; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed()); + drop(first); + } + /// #1806: only so many connections may wait to authenticate at once. One more is refused /// on the spot; once a waiting one authenticates or times out, its place is free again. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c8..0de1c6a7ea4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -738,8 +738,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { match action { Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), Some(crate::sign_ui::CertAction::Export(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d4..ef97cf1a411 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -679,6 +679,26 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } + // A double-click on grouped artwork ungroups it, whether or not it was selected first. Presses + // are counted here, with egui's own limits, as the text selection counts them (#1024): on a + // selected group the second press starts a drag, and a press and release that arrive in one + // pass are not reported as a double-click. + let press = ui.input(|i| i.pointer.primary_pressed().then(|| (i.time, i.pointer.interact_pos()))); + if allow_interaction + && let Some((now, Some(p))) = press + && let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p)) + && images.get(hit).is_some_and(|image| image.is_form) + { + let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let second = view.group_press.is_some_and(|(t, at, index)| index == hit && now - t <= max_delay && at.distance(p) <= max_dist); + if second { + view.group_press = None; + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: pdfcraft_engine::ImageEdit::Ungroup }); + return true; + } + view.group_press = Some((now, p, hit)); + } // The selected image: frame, corner handles, dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; @@ -844,7 +864,9 @@ pub(crate) fn image_input( } /// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link, -/// recognize and delete items). +/// recognize, ungroup and delete items). It shares its popup with the page's own menu, so +/// its widgets get their own id scope: the first items otherwise took the ids of the page menu's +/// first items ("Second use of widget ID"), and could not be told apart or reached. fn image_menu( ui: &mut egui::Ui, page: usize, @@ -852,6 +874,17 @@ fn image_menu( images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option, +) { + ui.push_id("image-menu", |ui| image_menu_items(ui, page, hit, images, view, action)); +} + +fn image_menu_items( + ui: &mut egui::Ui, + page: usize, + hit: usize, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + action: &mut Option, ) { use pdfcraft_engine::ImageEdit as E; let items: [(&str, Option); 4] = [ @@ -916,6 +949,14 @@ fn image_menu( ui.close(); } ui.separator(); + // Grouped artwork (a CAD export often draws a whole page as one group): its text, images and + // nested groups become separate objects. A double-click on the group does the same. Near the + // end of the menu: its first rows can lie under the page menu's, which share the popup. + if images.get(hit).is_some_and(|image| image.is_form) && ui.button(tl!("Ungroup")).clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Ungroup }); + ui.close(); + } if ui.button(tl!("Delete")).clicked() { view.image_selection = None; view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); @@ -1475,6 +1516,21 @@ mod tests { (app, ed) } + #[test] + fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { + // Loading a font used to only register it on the page: the paragraph kept its source + // face until the font was found again in the document-font menu (#2591). + let (mut app, ed) = preview_app(); + app.views[0].line_editor = Some(ed); + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); + let doc = app.session.get(app.views[0].id).unwrap(); + let lines = doc.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); + assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68d2..0e5307f0e94 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -24,6 +24,7 @@ pub enum CloseRequest { pub struct PendingOverwrite { pub id: pdfcraft_engine::DocId, pub dest: String, + target: SaveTarget, /// What was to run once the document is written (close its tab, quit). after: Box, } @@ -52,7 +53,10 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return true }; let info = &doc.info; let view = &mut self.views[i]; - if !matches!(edit, Edit::AddAnnotation(_)) { + if matches!(edit, Edit::AddAnnotation(_)) { + // A released drawing's raster can come now: it will have the annotation. + view.comments.drawings_committed(); + } else { // Only a new drawing waits on its raster; any other edit may remove or move it. view.comments.settling.clear(); } @@ -163,6 +167,10 @@ impl PdfCraftApp { true } Err(e) => { + if let Some(view) = self.views.get_mut(i) { + // A drawing whose annotation wasn't added must not stay painted. + view.comments.drawings_refused(); + } let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() }; self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]); false @@ -614,12 +622,12 @@ impl PdfCraftApp { let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { - app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after)); + app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; } }; - self.save_doc_to_then(id, &destination, Box::new(after)) + self.save_doc_to_then(id, &destination, target, Box::new(after)) } #[cfg(target_arch = "wasm32")] { @@ -674,14 +682,14 @@ impl PdfCraftApp { /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). /// Returns `true` if written now. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box) -> bool { + fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); if own && self.watch.disk_differs(id, dest) { - self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after }); + self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); self.dialog = Some(crate::Dialog::OverwriteChanged); return false; } - let saved = self.save_doc_to(id, dest); + let saved = self.save_doc_to(id, dest, target); if saved { after(self); } @@ -692,7 +700,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn overwrite_changed_file(&mut self) { let Some(p) = self.pending_overwrite.take() else { return }; - if self.save_doc_to(p.id, &p.dest) { + if self.save_doc_to(p.id, &p.dest, p.target) { (p.after)(self); } } @@ -708,17 +716,27 @@ impl PdfCraftApp { /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool { + fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even // if another tab is active by then. if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } - let Some(name) = self.session.get(id).map(|d| d.name.clone()) else { + let Some((name, full)) = self.session.get(id).map(|d| { + // Save As to another file is a clean copy, without the earlier revisions that still + // hold deleted pages or replaced text (#2165). A signed document stays incremental so + // its signatures keep covering the file. + let full = target == SaveTarget::As + && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) + && d.read_only_reason.is_none() + && !d.is_signed(); + (d.name.clone(), full) + }) else { self.notify_tr("The document was closed before it could be saved."); return false; }; - let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) { + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; + let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, Err(e) => { self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 23754e13efb..03c773807cf 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String { if reserved { format!("_{stem}") } else { stem.to_string() } } -/// A safe native Save suggestion for a PDF-supplied attachment name. -/// This changes only the suggestion, never the stored name or the selected destination. -#[cfg(any(test, not(target_arch = "wasm32")))] +/// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name +/// (which later names files written from that tab). It never changes the stored name or a +/// destination the user selected. pub(crate) fn attachment_file_name(name: &str) -> String { let cleaned: String = name .chars() @@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String { if reserved { format!("_{name}") } else { name.to_string() } } +/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or +/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data +/// streams. +pub(crate) fn is_plain_file_name(name: &str) -> bool { + let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()); + !(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':'))) +} + /// Pages ▸ Rotate Pages. #[derive(Clone, Debug, PartialEq)] pub struct RotateDraft { @@ -468,13 +476,26 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) - && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() + let bytes = Arc::new(bytes); + if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) { + return; + } + let Some((index, id)) = self.active_ids() else { return }; + let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return }; + // The font just loaded is the one the user wants: apply it to the paragraph, as + // choosing it from the document-font menu does, instead of only registering it (#2591). + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource) + }); + let ctx = self.ctx.clone(); + if resource.is_some() { + ed.font_resource = resource; + self.apply_line_format(index, ed, ctx.as_ref()); + } else if let Some(doc) = self.session.get(id) + && let Some(ctx) = ctx { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page); + self.views[index].line_editor = Some(ed); } } FilePurpose::Combine => self.stage_combine(files), @@ -667,7 +688,12 @@ impl PdfCraftApp { /// [`Self::write_files`], then `after` once every file is written: now, or on a later frame /// when the user chooses the folder. `after` never runs when writing fails or is cancelled. + /// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written. pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) { + if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) { + self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]); + return; + } #[cfg(not(target_arch = "wasm32"))] { let write = move |app: &mut Self, dir: std::path::PathBuf| { @@ -996,7 +1022,7 @@ impl PdfCraftApp { #[cfg(test)] mod stem_tests { - use super::{attachment_file_name, file_stem_from_user}; + use super::{attachment_file_name, file_stem_from_user, is_plain_file_name}; #[test] fn user_file_names_stay_inside_the_folder_and_writable_everywhere() { @@ -1049,4 +1075,36 @@ mod stem_tests { assert_eq!(attachment_file_name(&once), once); } } + + #[test] + fn write_files_refuses_names_that_leave_the_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let out = dir.join("a").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let absolute = dir.join("abs.pdf").to_string_lossy().into_owned(); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] { + assert!(!is_plain_file_name(bad), "{bad}"); + // A plain name first: nothing is written when any name in the batch is refused. + app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}"); + } + let names: Vec<_> = walk(&dir); + assert!(names.is_empty(), "{names:?}"); + app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert_eq!(walk(&dir), vec![out.join("x.pdf")]); + let _ = std::fs::remove_dir_all(&dir); + } + + fn walk(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) } + } + out + } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 5a0e6e9c96f..a1121dace17 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1703,6 +1703,7 @@ Couldn't use {name}: {e} تعذّر استخدام {name}: {e} Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e} Couldn't write {name}: {e} تعذّرت كتابة {name}: {e} + the name is not a plain file name الاسم ليس اسم ملف بسيطًا Wrote 1 file to {dir} كُتب ملف واحد في {dir} Wrote {n} files to {dir} كُتب {n} ملفات في {dir} No pages match those choices لا صفحات تطابق هذه الاختيارات @@ -1937,6 +1938,8 @@ Rotate image تدوير الصورة Flip image قلب الصورة Delete image حذف الصورة + Ungroup فك التجميع + Ungroup artwork فك تجميع العمل الفني Add header & footer إضافة رأس وتذييل Update header & footer تحديث الرأس والتذييل Update watermark تحديث العلامة المائية diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 90791f4a866..b2fe4cc07f1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1664,6 +1664,7 @@ Couldn't use {name}: {e} {name} не можа да бъде използван: {e} Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e} Couldn't write {name}: {e} {name} не можа да бъде записан: {e} + the name is not a plain file name името не е обикновено име на файл Wrote 1 file to {dir} Записан е 1 файл в {dir} Wrote {n} files to {dir} Записани са {n} файла в {dir} No pages match those choices Няма страници, отговарящи на този избор @@ -1961,6 +1962,8 @@ Delete document JavaScript Изтрий JavaScript на документа Delete field Изтрий полето Delete image Изтрий изображението + Ungroup Разгрупиране + Ungroup artwork Разгрупиране на графиката Delete link Изтрий връзката Delete page Изтрий страницата Device colour is used, but there is no PDF/A output intent Използва се цвят, зависим от устройството, но няма изходно намерение за PDF/A diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4bbb9a5e771..a2d0a3ce36f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1755,6 +1755,7 @@ key Space Mezerník Couldn't use {name}: {e} {name} nelze použít: {e} Couldn't extract pages: {e} Stránky nelze extrahovat: {e} Couldn't write {name}: {e} {name} nelze zapsat: {e} + the name is not a plain file name název není prostý název souboru Wrote 1 file to {dir} Zapsán 1 soubor do {dir} Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir}) No pages match those choices Těmto volbám neodpovídají žádné stránky @@ -1984,6 +1985,8 @@ key Space Mezerník Rotate image Otočit obrázek Flip image Převrátit obrázek Delete image Odstranit obrázek + Ungroup Zrušit seskupení + Ungroup artwork Zrušit seskupení grafiky Add header & footer Přidat záhlaví a zápatí Update header & footer Aktualizovat záhlaví a zápatí Update watermark Aktualizovat vodoznak diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7965435257a..de4a9174c5c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1637,6 +1637,7 @@ Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e} Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e} Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e} + the name is not a plain file name der Name ist kein einfacher Dateiname Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben No pages match those choices Keine Seiten entsprechen dieser Auswahl @@ -1873,6 +1874,8 @@ Rotate image Bild drehen Flip image Bild spiegeln Delete image Bild löschen + Ungroup Gruppierung aufheben + Ungroup artwork Grafikgruppierung aufheben Add header & footer Kopf- und Fußzeile hinzufügen Update header & footer Kopf- und Fußzeile aktualisieren Update watermark Wasserzeichen aktualisieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 57de6eef075..34ef6a7d121 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1215,6 +1215,7 @@ Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e} Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e} Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e} + the name is not a plain file name το όνομα δεν είναι απλό όνομα αρχείου Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir} Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir} No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές @@ -1351,6 +1352,8 @@ Rotate image Περιστροφή εικόνας Flip image Αναστροφή εικόνας Delete image Διαγραφή εικόνας + Ungroup Κατάργηση ομαδοποίησης + Ungroup artwork Κατάργηση ομαδοποίησης γραφικού Add header & footer Προσθήκη κεφαλίδας και υποσέλιδου Update header & footer Ενημέρωση κεφαλίδας και υποσέλιδου Update watermark Ενημέρωση υδατογραφήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4eb634468b..d9445ade252 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1714,6 +1714,7 @@ action wizard Start Iniciar Couldn't use {name}: {e} No se pudo usar {name}: {e} Couldn't extract pages: {e} No se pudieron extraer las páginas: {e} Couldn't write {name}: {e} No se pudo escribir {name}: {e} + the name is not a plain file name el nombre no es un nombre de archivo simple Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir} Wrote {n} files to {dir} Se han escrito {n} archivos en {dir} No pages match those choices Ninguna página coincide con esas opciones @@ -1952,6 +1953,8 @@ action wizard Start Iniciar Rotate image Girar imagen Flip image Voltear imagen Delete image Eliminar imagen + Ungroup Desagrupar + Ungroup artwork Desagrupar ilustración 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62acea1da8e..e54ead94647 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1632,6 +1632,7 @@ Couldn't use {name}: {e} Impossible d'utiliser {name} : {e} Couldn't extract pages: {e} Impossible d'extraire les pages : {e} Couldn't write {name}: {e} Impossible d'écrire {name} : {e} + the name is not a plain file name le nom n'est pas un simple nom de fichier Wrote 1 file to {dir} 1 fichier écrit dans {dir} Wrote {n} files to {dir} {n} fichiers écrits dans {dir} No pages match those choices Aucune page ne correspond à ces choix @@ -1868,6 +1869,8 @@ Rotate image Pivoter l'image Flip image Retourner l'image Delete image Supprimer l'image + Ungroup Dissocier + Ungroup artwork Dissocier l'illustration Add header & footer Ajouter un en-tête et un pied de page Update header & footer Mettre à jour l'en-tête et le pied de page Update watermark Mettre à jour le filigrane diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2238e1ade58..acdb6c91464 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1622,6 +1622,7 @@ Couldn't use {name}: {e} {name} használata nem sikerült: {e} Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e} Couldn't write {name}: {e} {name} írása nem sikerült: {e} + the name is not a plain file name a név nem egyszerű fájlnév Wrote 1 file to {dir} 1 fájl ide írva: {dir} Wrote {n} files to {dir} {n} fájl ide írva: {dir} No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak @@ -1858,6 +1859,8 @@ Rotate image Kép forgatása Flip image Kép tükrözése Delete image Kép törlése + Ungroup Csoportbontás + Ungroup artwork Ábra csoportbontása Add header & footer Fejléc és lábléc hozzáadása Update header & footer Fejléc és lábléc frissítése Update watermark Vízjel frissítése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3fa2263a0b5..91711aa4e37 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1709,6 +1709,7 @@ Couldn't use {name}: {e} Impossibile usare {name}: {e} Couldn't extract pages: {e} Impossibile estrarre le pagine: {e} Couldn't write {name}: {e} Impossibile scrivere {name}: {e} + the name is not a plain file name il nome non è un semplice nome file Wrote 1 file to {dir} Scritto 1 file in {dir} Wrote {n} files to {dir} Scritti {n} file in {dir} No pages match those choices Nessuna pagina corrisponde a queste scelte @@ -1943,6 +1944,8 @@ Rotate image Ruota immagine Flip image Capovolgi immagine Delete image Elimina immagine + Ungroup Separa + Ungroup artwork Separa illustrazione Add header & footer Aggiungi intestazione e piè di pagina Update header & footer Aggiorna intestazione e piè di pagina Update watermark Aggiorna filigrana diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ee9439e311e..46052a6a5ba 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1713,6 +1713,7 @@ Couldn't use {name}: {e} {name} を使用できませんでした: {e} Couldn't extract pages: {e} ページを抽出できませんでした: {e} Couldn't write {name}: {e} {name} に書き込めませんでした: {e} + the name is not a plain file name 名前が単独のファイル名ではありません Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました No pages match those choices 条件に一致するページはありません @@ -1950,6 +1951,8 @@ Rotate image 画像を回転 Flip image 画像を反転 Delete image 画像を削除 + Ungroup グループ解除 + Ungroup artwork アートワークのグループ解除 Add header & footer ヘッダーとフッターを追加 Update header & footer ヘッダーとフッターを更新 Update watermark 透かしを更新 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 827165ae3ac..0a1874dd95a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1634,6 +1634,7 @@ Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e} Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e} Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e} + the name is not a plain file name атау қарапайым файл атауы емес Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді @@ -1861,6 +1862,8 @@ Rotate image Кескінді бұру Flip image Кескінді аудару Delete image Кескінді жою + Ungroup Топтан шығару + Ungroup artwork Графиканы топтан шығару Add header & footer Үстіңгі және астыңғы колонтитулдарды қосу Update header & footer Үстіңгі және астыңғы колонтитулдарды жаңарту Update watermark Су белгісін жаңарту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 56a785cd3e6..b80d975c53f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1965,6 +1965,7 @@ comment menu Edit Redaguoti Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} + the name is not a plain file name vardas nėra paprastas failo vardas Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} Create link Kurti saitą Created 1 link Sukurtas 1 saitas @@ -1984,6 +1985,8 @@ comment menu Edit Redaguoti Delete document JavaScript Naikinti dokumento „JavaScript“ scenarijų Delete field Naikinti lauką Delete image Naikinti paveikslėlį + Ungroup Išgrupuoti + Ungroup artwork Išgrupuoti grafiką Delete link Naikinti saitą Delete page Naikinti puslapį Delete selected fields Naikinti pasirinktus laukus diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5bb2889c9d7..20c0fd04678 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option { list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag) } +/// The first supported language among BCP 47 tags in preference order, as the browser lists them +/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in +/// practice, and a page can't make Auto walk an unbounded one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported_tag>(tags: impl IntoIterator) -> Option { + tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim())) +} + +/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages` +/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads). +/// English when the browser offers none we have a catalog for. #[cfg(target_arch = "wasm32")] fn detect_system_lang() -> Lang { - Lang::EN + let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN }; + let mut tags: Vec = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect(); + tags.extend(navigator.language()); + first_supported_tag(&tags).unwrap_or(Lang::EN) } thread_local! { @@ -411,7 +425,38 @@ pub fn set_current(lang: Lang) { /// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages /// that capitalise nouns (German), where lowercasing would misspell it. pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> { - if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) } + match current().code() { + "de" => std::borrow::Cow::Borrowed(label), + "tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()), + _ => std::borrow::Cow::Owned(label.to_lowercase()), + } +} + +/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no +/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must +/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps +/// the English rule. +pub fn upper_label(english: &str) -> String { + let lang = current(); + let shown = tr(lang, english); + if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() } +} + +fn upper_turkish(c: char) -> std::char::ToUppercase { + match c { + 'i' => 'İ'.to_uppercase(), + 'ı' => 'I'.to_uppercase(), + c => c.to_uppercase(), + } +} + +fn lower_turkish(c: char) -> std::char::ToLowercase { + match c { + // `İ`.to_lowercase() is "i" plus a combining dot above. + 'İ' => 'i'.to_lowercase(), + 'I' => 'ı'.to_lowercase(), + c => c.to_lowercase(), + } } pub fn current() -> Lang { @@ -619,6 +664,21 @@ mod tests { assert_eq!(first_supported("("), None); } + /// #2954: the web build's Auto language follows the browser's preferred languages in order, + /// skipping ones without a catalog, and is English only when none is supported. + #[test] + fn browser_language_list_is_followed_in_order() { + assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); + assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); + assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); + assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); + assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); + assert_eq!(first_supported_tag(["", "xx"]), None); + assert_eq!(first_supported_tag(Vec::::new()), None); + let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); + assert_eq!(first_supported_tag(long), None, "only the first tags are read"); + } + #[test] fn windows_display_language_resolves_chinese_scripts() { for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { @@ -1961,6 +2021,20 @@ mod tests { set_current(Lang::EN); } + #[test] + fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { + set_current(Lang::from_code("tr").expect("tr registered")); + assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); + assert_eq!(upper_label("Signatures"), "İMZALAR"); + // A label with no Turkish row is still English and keeps the English rule. + assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); + assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); + assert_eq!(in_sentence("Işık"), "ışık"); + set_current(Lang::EN); + assert_eq!(upper_label("Advanced"), "ADVANCED"); + assert_eq!(in_sentence("Image"), "image"); + } + #[test] fn german_certificate_subject_has_a_distinct_context() { let de = Lang::from_code("de").expect("de registered"); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3dbb247056c..28d147a99d9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1696,6 +1696,7 @@ Couldn't use {name}: {e} Nie można użyć {name}: {e} Couldn't extract pages: {e} Nie można wyodrębnić stron: {e} Couldn't write {name}: {e} Nie można zapisać {name}: {e} + the name is not a plain file name nazwa nie jest zwykłą nazwą pliku Wrote 1 file to {dir} Zapisano 1 plik w {dir} Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n} No pages match those choices Żadna strona nie pasuje do wybranych opcji @@ -1931,6 +1932,8 @@ Rotate image Obróć obraz Flip image Odbij obraz Delete image Usuń obraz + Ungroup Rozgrupuj + Ungroup artwork Rozgrupuj grafikę Add header & footer Dodaj nagłówek i stopkę Update header & footer Zaktualizuj nagłówek i stopkę Update watermark Zaktualizuj znak wodny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8b3baa2d396..791b7a8e77b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1538,6 +1538,7 @@ Couldn't use {name}: {e} Não foi possível usar {name}: {e} Couldn't extract pages: {e} Não foi possível extrair páginas: {e} Couldn't write {name}: {e} Não foi possível gravar {name}: {e} + the name is not a plain file name o nome não é um nome de arquivo simples Wrote 1 file to {dir} 1 arquivo gravado em {dir} Wrote {n} files to {dir} {n} arquivos gravados em {dir} No pages match those choices Nenhuma página corresponde a essas opções @@ -1833,6 +1834,8 @@ Delete document JavaScript Excluir JavaScript do documento Delete field Excluir campo Delete image Excluir imagem + Ungroup Desagrupar + Ungroup artwork Desagrupar arte Delete link Excluir link Delete page Excluir página Device colour is used, but there is no PDF/A output intent A cor do dispositivo foi usada, mas não há intenção de saída PDF/A diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d0f8fc239c0..a6aa08b8a38 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1704,6 +1704,7 @@ Couldn't use {name}: {e} Не удалось использовать {name}: {e} Couldn't extract pages: {e} Не удалось извлечь страницы: {e} Couldn't write {name}: {e} Не удалось записать {name}: {e} + the name is not a plain file name имя не является простым именем файла Wrote 1 file to {dir} Записан 1 файл в {dir} Wrote {n} files to {dir} Записано {n} файлов в {dir} No pages match those choices Страницы не соответствуют этим выбору @@ -1938,6 +1939,8 @@ Rotate image Повернуть изображение Flip image Отразить изображение Delete image Удалить изображение + Ungroup Разгруппировать + Ungroup artwork Разгруппировать графику Add header & footer Добавить колонтитулы Update header & footer Обновить колонтитулы Update watermark Обновить водяной знак diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 92d9c9a6e33..7f1438ee719 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -479,6 +479,7 @@ Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e} Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e} Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e} + the name is not a plain file name ime ni preprosto ime datoteke Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e} Counterclockwise 90 degrees 90 stopinj v levo Country/Region Država/regija @@ -553,6 +554,8 @@ Delete document JavaScript Izbriši JavaScript dokumenta Delete field Izbriši polje Delete image Izbriši sliko + Ungroup Razdruži + Ungroup artwork Razdruži grafiko Delete link Izbriši povezavo Delete page Izbriši stran Delete pages Izbriši strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 07b716eac13..e2b7d8015be 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1623,6 +1623,7 @@ action wizard Start ప్రారంభించు Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e} Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e} Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e} + the name is not a plain file name పేరు సాధారణ ఫైల్ పేరు కాదు Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు @@ -1857,6 +1858,8 @@ action wizard Start ప్రారంభించు Rotate image చిత్రాన్ని తిప్పు Flip image చిత్రాన్ని తిరగేయి Delete image చిత్రాన్ని తొలగించు + Ungroup సమూహాన్ని విడదీయి + Ungroup artwork చిత్రకళ సమూహాన్ని విడదీయి Add header & footer హెడర్ & ఫుటర్ జోడించు Update header & footer హెడర్ & ఫుటర్‌ను అప్‌డేట్ చేయి Update watermark వాటర్‌మార్క్‌ను అప్‌డేట్ చేయి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 30e19f2132f..8ce8900af77 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1603,6 +1603,7 @@ Couldn't use {name}: {e} {name} kullanılamadı: {e} Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e} Couldn't write {name}: {e} {name} yazılamadı: {e} + the name is not a plain file name ad, basit bir dosya adı değil Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı No pages match those choices Bu seçimlerle eşleşen sayfa yok @@ -1833,6 +1834,8 @@ Rotate image Resmi döndür Flip image Resmi çevir Delete image Resmi sil + Ungroup Grubu çöz + Ungroup artwork Çizim grubunu çöz Add header & footer Üstbilgi ve altbilgi ekle Update header & footer Üstbilgi ve altbilgiyi güncelle Update watermark Filigranı güncelle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c60799ef1f6..11ded7d509d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1701,6 +1701,7 @@ Couldn't use {name}: {e} Не вдалося використати {name}: {e} Couldn't extract pages: {e} Не вдалося видобути сторінки: {e} Couldn't write {name}: {e} Не вдалося записати {name}: {e} + the name is not a plain file name назва не є простою назвою файлу Wrote 1 file to {dir} Записано 1 файл у {dir} Wrote {n} files to {dir} Записано файлів ({n}) у {dir} No pages match those choices Жодна сторінка не відповідає вибраним параметрам @@ -1935,6 +1936,8 @@ Rotate image Повернути зображення Flip image Віддзеркалити зображення Delete image Видалити зображення + Ungroup Розгрупувати + Ungroup artwork Розгрупувати графіку Add header & footer Додати колонтитули Update header & footer Оновити колонтитули Update watermark Оновити водяний знак diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b0727552362..68c7e2d5712 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1955,6 +1955,7 @@ comment menu Edit Sửa Couldn't use the photo: {e} Không thể dùng ảnh: {e} Couldn't use {name}: {e} Không thể dùng {name}: {e} Couldn't write {name}: {e} Không thể ghi {name}: {e} + the name is not a plain file name tên không phải là tên tệp đơn thuần Couldn't write {path}: {e} Không thể ghi {path}: {e} Create link Tạo liên kết Created 1 link Đã tạo 1 liên kết @@ -1974,6 +1975,8 @@ comment menu Edit Sửa Delete document JavaScript Xóa JavaScript của tài liệu Delete field Xóa trường Delete image Xóa hình ảnh + Ungroup Bỏ nhóm + Ungroup artwork Bỏ nhóm hình vẽ Delete link Xóa liên kết Delete page Xóa trang Delete selected fields Xóa các trường đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2297d1269ab..403da3000b7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1671,6 +1671,7 @@ Couldn't use {name}: {e} 无法使用{name}:{e} Couldn't extract pages: {e} 无法提取页面:{e} Couldn't write {name}: {e} 无法写入{name}:{e} + the name is not a plain file name 名称不是单纯的文件名 Wrote 1 file to {dir} 已将 1 个文件写入{dir} Wrote {n} files to {dir} 已将{n}个文件写入{dir} No pages match those choices 所选条件没有匹配的页面 @@ -1971,6 +1972,8 @@ Delete document JavaScript 删除文档 JavaScript Delete field 删除字段 Delete image 删除图像 + Ungroup 取消组合 + Ungroup artwork 取消组合图稿 Delete link 删除链接 Delete page 删除页面 Device colour is used, but there is no PDF/A output intent 使用了设备颜色,但未设置 PDF/A 输出意图 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2ddb33dd6d7..fe3772303e8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1615,6 +1615,7 @@ Couldn't use {name}: {e} 無法使用{name}:{e} Couldn't extract pages: {e} 無法提取頁面:{e} Couldn't write {name}: {e} 無法寫入{name}:{e} + the name is not a plain file name 名稱不是單純的檔案名稱 Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} No pages match those choices 所選條件沒有匹配的頁面 @@ -1937,6 +1938,8 @@ Rotate image 旋轉圖片 Flip image 翻轉圖片 Delete image 刪除圖片 + Ungroup 取消群組 + Ungroup artwork 取消群組圖稿 Add header & footer 新增頁首與頁尾 Update header & footer 更新頁首與頁尾 Update watermark 更新浮水印 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c6e..e173b93d4a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1520,7 +1520,8 @@ impl PdfCraftApp { match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Ok(bytes), true) => { - if let Err(e) = self.open_bytes(&att.name, None, bytes) { + // The tab name later names files written from the tab (Split, Extract pages). + if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); } } @@ -1540,6 +1541,15 @@ impl PdfCraftApp { } } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). + pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { + let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; + // The menus offer the items only for a listed file, so a miss is a comment gone since. + if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) { + self.attachment_action(doc, i, open); + } + } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves /// the frame to the app and Use system title bar is off. pub fn draws_window_frame(&self) -> bool { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d691488076a..69600dac0e4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { - let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; - if app.apply_edit(edit) { - ed.applied(); - if let Some(doc) = app.session.get(app.views[i].id) - && let Some(block) = doc.text_blocks(ed.page).get(ed.block) - { - ed.refresh_source(block, doc.text_lines(ed.page)); - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } - } - app.views[i].line_editor = Some(ed); + app.apply_line_format(i, ed, Some(ui.ctx())); } ui.add_space(6.0); ui.separator(); @@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.separator(); } +impl PdfCraftApp { + /// Apply the open paragraph's formatting as one change; the paragraph stays open. + pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { + let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) { + ed.applied(); + if let Some(doc) = self.session.get(self.views[index].id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + if let Some(ctx) = ctx { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); + } + } + } + self.views[index].line_editor = Some(ed); + } +} + // ───────────────────────────────────────────────────────────────────────────── right panels pub(crate) enum Nav { @@ -784,14 +793,16 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.start_signing(page, None, Some(field), None); } Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) { + app.comment_attachment_action(id, p, i, open); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 4e0f0658851..f4e039d512d 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -1103,7 +1103,7 @@ pub(crate) fn body( ); // Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on. ui.add_space(6.0); - egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint)) + egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint)) .id_salt("print-advanced") .default_open(d.as_image) .show(ui, |ui| { diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 988833e30c0..3cb67bb8ee1 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry { } impl PdfCraftApp { + /// Export certificate: write `c` as PEM into a chosen folder, named after its subject. + pub(crate) fn export_certificate(&mut self, c: &Certificate) { + let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name())); + self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate"); + } + /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { self.refresh_os_key_store_ids(); @@ -1496,6 +1502,23 @@ mod tests { Ok(PathBuf::from(&app.digital_ids[i].path)) } + #[test] + fn an_exported_certificate_is_written_inside_the_chosen_folder() { + let dir = scratch(); + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let key = PrivateKey::generate_p256().unwrap(); + let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap(); + let mut app = PdfCraftApp::new(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + app.export_certificate(&cert); + let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(written, vec![std::ffi::OsString::from("out")]); + let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap(); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn signature_status_style_gives_each_verdict_its_panel_icon() { assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b61..c39f4cf05ee 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); ui.add_space(2.0); } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 914bc281b84..9a3716c3eeb 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -7,6 +7,8 @@ use egui_kittest::kittest::Queryable; use pdfcraft_render::Annotation; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; +mod support; + /// Two 300×200 pages of Helvetica text. const TEXT_FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -406,8 +408,11 @@ fn shapes_and_ink_are_drawn_by_dragging() { assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}"); } -/// Press, drag and release a stroke, running only one frame after the release. +/// Press, drag and release a stroke, running only one frame after the release. Finished +/// renders are held back from the view (`hold_rasters`), so the raster the commit asks for can't +/// arrive before the test looks, however fast it renders; a test lets it through by clearing it. fn stroke_without_settling(h: &mut Harness<'static, PdfCraftApp>, a: (f32, f32), b: (f32, f32)) { + h.state_mut().views[0].hold_rasters = true; let (from, to) = (at(h, a.0, a.1), at(h, b.0, b.1)); h.hover_at(from); h.run_steps(1); @@ -429,7 +434,10 @@ fn a_released_pen_stroke_stays_drawn_until_the_page_raster_has_it() { stroke_without_settling(&mut h, (40.0, 100.0), (140.0, 60.0)); assert_eq!(comments(&h).len(), 1, "the stroke is committed"); assert_eq!(h.state().views[0].comments.settling_len(), 1, "still drawn from its points"); - settle(&mut h); + h.run_steps(5); + assert_eq!(h.state().views[0].comments.settling_len(), 1, "drawn from its points for as long as its raster takes"); + h.state_mut().views[0].hold_rasters = false; + support::wait_until(&mut h, "the page raster with the stroke", |app| app.views[0].comments.settling_len() == 0); assert_eq!(h.state().views[0].comments.settling_len(), 0, "the raster draws it now"); } @@ -1205,6 +1213,91 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying +/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference +/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones. +fn attachment_comment_fixture() -> Vec { + let inner = "%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\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.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()); + out +} + +fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + setup(&mut app); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect() +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_page_menu() { + let mut h = attachment_harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + assert_eq!(comments(&h)[0].subtype, "FileAttachment"); + click_pt(&mut h, (110.0, 112.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected"); + let p = at(&h, 110.0, 112.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); + // The Attachments panel is closed, so these items can only come from the comment's menu. + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_card_menu() { + let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap()); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text"); + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + /// Drag with the middle button (the mouse wheel) in small steps. fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db525751f..5dace3ac46b 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() { assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]); } +#[test] +fn reverse_pages_reverses_the_selection_or_else_the_whole_document() { + let mut h = organize(4); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]); + h.get_by_label("Page 1").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]); +} + #[test] fn command_click_toggles_and_rotation_applies_to_selection() { let mut h = organize(3); @@ -520,6 +535,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() { let _ = std::fs::remove_file(out); } +#[test] +fn save_as_to_a_new_file_drops_earlier_revisions() { + // #2165: Save As appended a revision, so a deleted page stayed in the new file's first one. + let out = temp_path("save-as-clean.pdf"); + let mut h = organize(3); + h.get_by_label("Page 2").hover(); + h.run_steps(2); + h.get_by_label("Delete page 2").click(); + h.run_steps(3); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As)); + let saved = std::fs::read(&out).expect("file written"); + let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count(); + assert_eq!(count(b"%%EOF"), 1, "one revision only"); + assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone"); + assert!(!dirty(&h)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice()); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]); + let _ = std::fs::remove_file(out); +} + #[test] fn closing_a_dirty_tab_asks_and_cancel_keeps_it() { let mut h = organize(2); @@ -2800,6 +2836,92 @@ fn editing_existing_form_artwork_on_the_page() { check_existing_artwork("figure.pdf", write_incremental(&doc, &SaveOptions::default()).unwrap()); } +/// A page drawn as one Form XObject, as CAD exports draw it: the group's menu offers Ungroup, +/// and a double-click on the group does the same, after which its text is a paragraph of the +/// page and the group is gone. +#[test] +fn ungrouping_page_sized_form_artwork_frees_its_text() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let font = doc.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(font)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut form = Dict::new(); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec())); + form.set(b"Resources".to_vec(), Object::Dict(res)); + let artwork = doc.add(Object::Stream(Stream::flate(form, b"0.9 0.9 0.6 rg 10 10 180 280 re f 0 g BT /F1 14 Tf 30 200 Td (Schema) Tj ET"))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Page Do"))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + let mut xo = Dict::new(); + xo.set(b"Page".to_vec(), Object::Ref(artwork)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1500.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("cad.pdf", None, bytes.clone()).expect("opens"); + app + }); + for _ in 0..40 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().text_blocks(0).is_empty(), "the text is inside the group"); + h.state_mut().set_option("quick", "edit-text").unwrap(); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // The middle of the page: inside the group's fill, below its text. + let at = r.center(); + let ungrouped = |h: &Harness<'static, PdfCraftApp>| { + let doc = h.state().session.get(id).unwrap(); + doc.can_undo() == Some("Ungroup artwork") + && doc.page_images(0).is_empty() + && doc.text_blocks(0).iter().map(|b| b.text.as_str()).collect::>() == ["Schema"] + }; + + // A double-click on the group. The harness clock moves on half a second between two clicks, + // past egui's 0.3 s, so the test allows a second. + h.ctx.options_mut(|o| o.input_options.max_double_click_delay = 1.0); + h.hover_at(at); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + } + h.run_steps(4); + assert!(ungrouped(&h), "a double-click ungroups"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "undo regroups"); + + // The group's menu. + right_click_at(&mut h, at); + h.run_steps(1); + assert!(h.state().views[0].image_selection.is_some(), "the group is selected"); + h.get_by_label("Ungroup").click(); + h.run_steps(4); + assert!(ungrouped(&h), "the menu item ungroups"); +} + fn check_existing_artwork(name: &'static str, bytes: Vec) { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); @@ -4225,9 +4347,25 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { .filter(|b| b.width() < page.width() - 1.0) .collect() }; + // The dragged picture is cut from the page raster, which a render thread delivers after + // however many frames it takes (more on a slow runner): wait for it on screen, by state. + let raster_shown = |h: &Harness<'static, PdfCraftApp>| { + h.output().shapes.iter().any(|s| { + matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.calc_bounds().width() >= page.width() - 1.0) + }) + }; + let wait_for_raster = |h: &mut Harness<'static, PdfCraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().render_pending() || !raster_shown(h) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for the page raster"); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; let start = frame(&h).expect("the selected image has a frame"); let (grip, fixed) = (start.right_top(), start.left_bottom()); for factor in [0.6, 1.3] { + wait_for_raster(&mut h); let to = fixed + (grip - fixed) * factor; h.hover_at(grip); h.run_steps(1); diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/pickers.rs index e7db8cffa68..bf9df10b399 100644 --- a/crates/ui-egui/tests/pickers.rs +++ b/crates/ui-egui/tests/pickers.rs @@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() { /// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion. fn attachment_fixture() -> Vec { - let name = "invoice\u{202e}fdp.exe"; + attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes") +} + +/// A one-page PDF with one embedded file whose /UF name is `name`. +fn attachment_pdf(name: &str, data: &[u8]) -> Vec { let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::(); - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_string(), - "<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(), - format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"), + let objs: [Vec; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(), + [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(), + format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").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{o}\nendobj\n", i + 1).as_bytes()); + 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(b"xref\n0 6\n0000000000 65535 f \n"); @@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() { assert_eq!(doc.info.attachments[index].name, raw_name); assert_eq!(doc.bytes.as_ref(), &original); } + +/// Every file under `dir`, recursively, sorted. +fn files_under(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) } + } + out.sort(); + out +} + +#[test] +fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() { + let dir = AttachmentDir::new(); + let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned(); + for name in ["../../x.pdf", absolute.as_str()] { + let out = dir.0.join("a").join("b").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let mut h = harness(&[]); + h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().attachment_action(id, 0, true); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab"); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1))); + let written = files_under(&dir.0); + assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}"); + assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}"); + // Extract writes page 1 under the name Split gave it. + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.state_mut().extract_selection(); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast); + assert_eq!(files_under(&dir.0), written, "{name}"); + std::fs::remove_dir_all(dir.0.join("a")).unwrap(); + } +} diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index ccb6dece7b5..c7fd4c42fa0 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -285,6 +285,38 @@ fn certifying_without_a_visible_signature() { h.get_by_label_contains("Certified by Test Signer EC"); } +#[test] +fn save_as_keeps_a_signed_document_incremental() { + // #2165: Save As to a new file rewrites unsigned documents in full; a signed one is appended + // to, or its signature would no longer cover the file. + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap(); + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("contract.pdf", None, std::sync::Arc::new(FIXTURE.to_vec()), None).unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..Default::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + let out = dir().join("copy.pdf"); + let (bytes, cert) = (signed.to_vec(), digital_id.certificate.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("contract.pdf", None, bytes).expect("opens"); + app.session.set_trusted_certificates(vec![cert]); + app + }); + h.run_steps(4); + let status = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).map(|x| x.status) + }; + assert_eq!(status(&h), Some(SignatureStatus::Valid)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "a signed document can be saved as a copy"); + let saved = std::fs::read(&out).expect("file written"); + assert!(saved.starts_with(&signed), "the signed revision is kept as it was"); + assert_eq!(status(&h), Some(SignatureStatus::Valid)); +} + /// The fixture with an empty signature field across the bottom of the page. const WITH_FIELD: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c47..0082e62fd8e 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -839,6 +839,9 @@ fn files_and_quit_from_the_operating_system() { let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])])); let q = queue.clone(); let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + // The file is read on a worker thread, after however many frames that takes: it must still + // be there until the tab has opened. + support::wait_until(&mut h, "the file the operating system opened", |app| !app.views.is_empty()); std::fs::remove_file(&path).ok(); assert_eq!(h.state().views.len(), 1, "the file opens in a tab"); h.get_by_label_contains(&name); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 3f4420d3b4b..5ea5452fa71 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1046,6 +1046,47 @@ fn the_hand_tool_pans_by_dragging() { assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand"); } +#[test] +fn the_hand_tool_moves_the_page_with_the_pointer() { + // With the Hand tool the page stays under the pointer, point for point, in the frame the + // pointer moves (#924, #1992). The drag used to reach the scroll offset only after the frame + // was drawn (and, before the page view stopped animating its scrolls, eased in over several + // frames), so the page trailed behind the pointer; the points moved before egui tells a drag + // from a click were lost on top. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "600"), ("quick", "hand")]); + let start = h.state().views[0].viewport_rect().center(); + h.hover_at(start); + h.run_steps(2); + let page = |h: &Harness<'static, PdfCraftApp>| rect(h, 0).expect("page 1 on screen").min; + let grabbed = page(&h); + let click_dist = h.ctx.options(|o| o.input_options.max_click_dist); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.event(button(start, true)); + h.step(); + // A slow start, two points a frame, then quick strokes both ways. Once the pointer is further + // than a click may wander, the page has moved exactly as far as it, in every frame. + let mut pointer = start; + let strokes = [egui::vec2(-2.0, -2.0); 5].into_iter().chain([egui::vec2(-40.0, -150.0), egui::vec2(25.0, 60.0)]); + for stroke in strokes { + pointer += stroke; + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + if (pointer - start).length() > click_dist { + assert_eq!(page(&h) - grabbed, pointer - start, "the page keeps up with the pointer in the same frame"); + } + } + // Held still, it stays: nothing is left to ease in. + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "nothing moves the page once the pointer stops"); + // Let go mid-stroke, it stops with the pointer: the Hand tool doesn't fling the page on. + pointer += egui::vec2(0.0, -30.0); + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + h.event(button(pointer, false)); + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "the page stays where it was let go"); +} + #[test] fn required_fields_get_a_red_border_when_highlighting() { let _gpu = gpu(); diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 2b179e2007a..0605298e20f 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,6 +18,11 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). +Plain AcroForm XML imports retain all direct text around comments and processing instructions, +including CDATA and character references. Nested-element and XFA interpretation are unchanged. +XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with +character references, so XML attribute normalization cannot change their identities. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 50326c4e71d..9d7b9839d7a 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -197,6 +197,9 @@ fn esc(s: &str) -> String { '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), + // XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references. + '\t' => o.push_str(" "), + '\n' => o.push_str(" "), '\r' => o.push_str(" "), c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {} c => o.push(c), @@ -1114,7 +1117,8 @@ fn import_table(doc: &mut Document, text: &str) -> Result { .map(|c| { let name = c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string()); - (name, vec![c.text().unwrap_or("").to_string()]) + // Comments and processing instructions do not end a field's character data. + (name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()]) }) .collect() } else if t.lines().next().is_some_and(|l| l.contains('\t')) { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index d244df1adf0..47952e642bf 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -142,6 +142,108 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +#[test] +fn xml_exports_preserve_whitespace_in_field_names() { + let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"]; + let value = "value\twith\nlines\r\nand & \"quotes\""; + let mut src = one_field(); + pdfcraft_forms::delete_field(&mut src, "Name").unwrap(); + for (index, name) in names.iter().enumerate() { + let placeholder = format!("field{index}"); + add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap(); + // Existing PDFs can have leading/trailing whitespace even though add_field trims it. + let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap(); + src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let src = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for format in [Format::Xml, Format::Xfdf] { + let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) }; + let xml = roxmltree::Document::parse(&exported).unwrap(); + let exported_names: Vec<_> = if format == Format::Xfdf { + xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect() + } else { + xml.root_element() + .children() + .filter(|n| n.is_element()) + .map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap()) + .collect() + }; + assert_eq!(exported_names, names, "{format:?}: {exported}"); + let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap(); + for name in names { + set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap(); + } + let report = import(&mut dst, exported.as_bytes()).unwrap(); + assert_eq!(report.fields, names.len(), "{format:?}: {report:?}"); + assert!(report.rejected.is_empty(), "{report:?}"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(values(&reopened), values(&src), "{format:?}"); + } + assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF"); +} + +#[test] +fn xfdf_exports_preserve_whitespace_in_comment_attributes() { + let mut src = one_field(); + let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment }; + let names = ["note\tline\nidentity", "note line identity"]; + let author = "Ada\tLovelace\nReview"; + let subject = "Subject\twith\nlines\r\nand & quotes\""; + for name in names { + add_annotation( + &mut src, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() }, + &Meta { id: name.into(), date: None }, + ) + .unwrap(); + } + let annotation_refs = |doc: &Document| { + let page = pdfcraft_model::pages(doc)[0].obj; + doc.get(page) + .as_dict() + .unwrap() + .get(b"Annots") + .map(|a| doc.resolve(a)) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text")) + .collect::>() + }; + for annotation in annotation_refs(&src) { + src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap(); + } + let source_bytes = write_full(&src, &SaveOptions::default()).unwrap(); + let mut dst = Document::open(Arc::new(source_bytes)).unwrap(); + let file = "form\twith\nlines.pdf"; + let xfdf = export_xfdf(&dst, true, false, file); + let xml = roxmltree::Document::parse(&xfdf).unwrap(); + let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect(); + assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::>(), names); + for note in notes { + assert_eq!(note.attribute("title"), Some(author)); + assert_eq!(note.attribute("subject"), Some(subject)); + } + assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file)); + let before = annotation_refs(&dst); + let report = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(report.comments, names.len()); + assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments"); + let saved = write_full(&dst, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + for (annotation, name) in annotation_refs(&reopened).iter().zip(names) { + let dict = reopened.get(*annotation); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author)); + assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject)); + } +} + #[test] fn xfdf_round_trips_comments_and_values() { let src = filled(); @@ -1011,3 +1113,34 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); } } + +#[test] +fn xml_form_import_preserves_text_around_comments_and_instructions() { + for (body, expected) in [ + ("leftright", "leftright"), + ("leftmiddleright", "leftmiddleright"), + ("]]>&right", "&right"), + (" left right ", " left right "), + ("", ""), + ] { + let mut original = one_field(); + let root = original.root().unwrap(); + original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap(); + set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap(); + let source = write_full(&original, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(source.clone())).unwrap(); + let xml = format!("{body}"); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty()); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap(); + let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] }; + assert_eq!(name.value, want, "XML body: {body}"); + let root = reopened.get(reopened.root().unwrap()); + assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained"); + } + assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]); + } +} diff --git a/deny.toml b/deny.toml index 2362d56822f..ed9c26c2066 100644 --- a/deny.toml +++ b/deny.toml @@ -48,6 +48,13 @@ ignore = [ # winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows # and is not in the binary. { id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" }, + # zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed + # release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` -> + # `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform. + # Revisit when the accesskit/zbus chain moves to a maintained successor. + { id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" }, ] # egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md diff --git a/docs/architecture.md b/docs/architecture.md index aa91c36c4ee..6b44817e154 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod 3. After every edit an incremental write produces the working file (original bytes plus one revision); the view is refreshed from those bytes, so what you see is what Save writes. 4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A - conversion, print imposition, a changed password, Save As with garbage collection. + conversion, print imposition, a changed password, Save As to another file (garbage-collected, + unless the document is signed). 5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected from rewrites. diff --git a/docs/editing-images.md b/docs/editing-images.md index eba918ef391..7d991eac524 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -8,7 +8,8 @@ together with its pixels, including nested graphics states, intervening state op and paths split across content streams. A clip shared with other content stays where it is. When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't -nest one more graphics state each time. +nest one more graphics state each time. Moving a privately cropped figure again likewise +changes the `cm` before its clip instead of adding another. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects @@ -16,14 +17,27 @@ this object using its `BBox` and optional `Matrix` and edits it as a whole. Its content, resources, clipping box, and transparency settings remain intact. Moving or deleting one use of a shared figure does not alter its other placements or pages. +**Ungroup** (in a group's context menu, above Delete, or a double-click on the group) puts the +form's content on the page in place of the group, so its text becomes editable paragraphs and +its images and nested groups separate objects. CAD exports (WSCAD among them) draw a whole page +as one Form, which otherwise selects as one page-sized group. The content is written exactly as +ISO 32000-2 §8.10.1 paints a form: inside a saved graphics state, through its `Matrix`, clipped +to its `BBox`; its bytes are kept and only resource names that clash with the page's are +renamed. The form object and its other placements are left as they are. One level is ungrouped +at a time, as one undo step. A group stays grouped, with the reason, when inlining could change +the page: a transparency group whose blending would change, a form in the structure tree +(`StructParent`, `StructParents`), other default colour spaces, damaged content, or content that +restores or closes more than it opens. Renderers may differ by an anti-aliasing step where the +box and an identical clip of the content meet on a pixel edge. + **Replace Image** and **Save Image As** apply only to raster Image XObjects; these menu -items are disabled for grouped Form artwork. The tool does not ungroup Forms, edit their -individual internal elements, select arbitrary ungrouped vector paths, or edit inline +items are disabled for grouped Form artwork. Without ungrouping, the tool does not edit a +Form's individual internal elements; it does not select arbitrary vector paths or edit inline images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace. For automation, `page_images` lists both kinds with `kind: "image"` or `kind: "form"`. Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use the returned -number with `image_edit` (move, rotate, flip_horizontal, flip_vertical, delete). +number with `image_edit` (move, rotate, flip_horizontal, flip_vertical, delete, ungroup). Coordinates are top-left-origin page points. `image_save` and `image_edit`'s replace action return a clear error for Form artwork. diff --git a/docs/gaps.md b/docs/gaps.md index a3ce0d1f17c..405fe4192aa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,8 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-17 · **Change:** minor (gap 3: grouped Form artwork can be ungrouped, `/ToUnicode /Identity-H` read as Unicode; gap 16: picture and page menus share one popup; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -96,6 +98,14 @@ update the numbers. semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same baseline is still replaced. Estimates are unchanged. +- **Fork progress (2026-10-17, partial):** pages drawn as one Form XObject (CAD exports such as + WSCAD) selected as one page-sized group; Ungroup (context menu, double-click, `image_edit` + `ungroup`) now puts a form's content on the page as §8.10.1 paints it, so its paragraphs and + pictures can be edited one by one, and composite fonts with `/ToUnicode /Identity-H` read as + Unicode (before, such pages had no selectable text). Still partial: Acrobat edits inside forms + without ungrouping; transparency groups whose blending would change, tagged forms and other + default colour spaces stay grouped; vector paths are still not selectable (`edit.vector-edit`). + `edit.form-content-editing` is partial. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping @@ -140,6 +150,9 @@ update the numbers. (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place when a figure is marked decorative (#1324). The rest of the backlog is untriaged. +- **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of + accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic + filter and COS stream regressions cover refusal at the limit and decoding within it. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. @@ -315,7 +328,10 @@ update the numbers. - **Missing:** moving several selected objects across pages (same-page moves shipped, #844), single-key tool accelerators, rulers, grids and guides, loupe and dynamic zoom, multiple windows and split view, comment pop-ups that can be moved, connector lines, customisable quick tools; shortcut display and tool-mode bugs (#746, #721), macOS window controls - (#789). + (#789). In Edit text & images the picture menu and the page menu draw into one popup over each + other (the picture menu's first items lie under the page menu's Copy and Comments panel), and a + menu item that lies over another page's picture or group is taken by that page (Delete does + nothing there); found 2026-10-17, present before the Ungroup change, not fixed. - **Estimate:** 30–60 h. **Doc:** [ui-parity.md](ui-parity.md). ### 17. Certificate security and other protect gaps @@ -378,7 +394,9 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Gap 3: Ungroup for grouped Form artwork and `/ToUnicode /Identity-H` names read as Unicode (`edit.form-content-editing`, partial). Gap 16: the shared picture/page menu popup in Edit text & images (pre-existing, not fixed). Estimates unchanged. | | 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | +| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | | 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | | 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4d..0e3d25561c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,10 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -97,11 +102,11 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | @@ -117,14 +122,15 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| 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: 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. 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. | | 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. 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. 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). 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. | | 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: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | 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). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | @@ -179,6 +185,11 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | +| 2026-10-17 | minor | M7: Ungroup for grouped Form artwork (CAD pages drawn as one Form XObject become editable text and objects; `image_edit` ungroup); `/ToUnicode /Identity-H` names read as Unicode (hayro patch 35, `crates/fonts`). New parity entry `edit.form-content-editing` (partial). Estimates unchanged. | +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | +| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | +| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | @@ -233,5 +244,6 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | +| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7698baeefb..05401ba529d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (C: `edit.form-content-editing` added as partial; area C and All counts by one entry; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) @@ -198,7 +199,7 @@ full parity in that area. |---|---|---|---|---|---|---|---| | A Core model and fidelity | 56 | 42 | 2 | 76.8% | 70% | 25–45 | Parser, repair, R2–R6 encryption, incremental and atomic saves, undo, autosave, crash recovery, revisions. Encrypted output opens in Acrobat (#774). Missing: lazy loading of GB files, linearization, PDF 2.0 extras, Arlington validation, public-key security, own image codecs | | B View and navigation | 92 | 52 | 11 | 62.5% | 55% | 110–190 | Shell, layouts, zoom, find, panels, tiles, web build, 500-page scrolling. **Pages are drawn by the vendored `hayro`** (22 + 9 local patches): all 7 rendering P0s are partial. Missing: own renderer or a decided replacement (#841), multiple windows, split view, rulers/guides, loupe, reflow, search across files | -| C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | +| C Content editing | 62 | 29 | 8 | 53.2% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), grouped Form artwork can be ungrouped into editable text and objects (CAD pages drawn as one Form), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | | F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | @@ -210,7 +211,7 @@ full parity in that area. | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report). Missing: most Preferences pages, side-by-side compare, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | -| **All** | **808** | **423** | **74** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | +| **All** | **809** | **423** | **75** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | Shipped features rest on thin evidence: 141 of 423 cite exactly one test, and only a handful cite an external oracle (`pdftotext`, `pdfsig`, OpenSSL, `qpdf --check`, a corpus). @@ -292,6 +293,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | C: new partial entry `edit.form-content-editing` (Ungroup for grouped Form artwork; `/ToUnicode /Identity-H` names read as Unicode): C 61 → 62 features, 7 → 8 partial, breadth 53.3% → 53.2%; All 808 → 809, 74 → 75 partial, breadth unchanged at 56.9%. Only this entry's effect is applied: the table already lagged the checklist, which `cargo xtask parity` now counts as 816 features, 437 shipped, 94 partial. Ready and hours unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35f6..1394ce5cdec 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -118,8 +118,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"] -notes = "" +evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"] +notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation." [[feature]] id = "core.image-filters" @@ -1070,8 +1070,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] -notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily" +evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] +notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop" [[feature]] id = "view.rotate-view" @@ -2121,6 +2121,18 @@ tools = ["image_edit", "image_hit"] evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/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/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/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/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/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] 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; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +[[feature]] +id = "edit.form-content-editing" +title = "Edit text and images inside grouped Form artwork" +area = "C" +tier = "P1" +milestone = "M7" +status = "partial" +commands = ["edit.edit_text"] +tools = ["image_edit"] +evidence = ["crates/edit/src/tests/ungroup.rs::ungrouping_a_page_sized_form_makes_its_content_editable", "crates/edit/src/tests/ungroup.rs::ungrouping_renames_clashing_resources", "crates/edit/src/tests/ungroup.rs::ungrouping_keeps_the_page_state_balanced", "crates/edit/src/tests/ungroup.rs::ungrouping_refuses_groups_whose_blending_matters", "crates/edit/src/tests/ungroup.rs::ungrouping_refuses_what_it_cannot_keep", "crates/edit/src/tests/ungroup.rs::ungrouping_keeps_optional_content_and_inline_image_spaces", "crates/edit/src/tests/ungroup.rs::ungrouping_changes_only_the_chosen_placement", "crates/engine/src/tests/ungroup.rs::ungrouping_cad_artwork_keeps_every_pixel_and_frees_its_objects", "crates/engine/src/tests/ungroup.rs::a_refused_ungroup_changes_nothing", "crates/automation/tests/automation.rs::ungrouping_form_artwork_through_tools", "crates/ui-egui/tests/editing.rs::ungrouping_page_sized_form_artwork_frees_its_text", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"] +notes = "CAD and print exports (WSCAD among them) draw a whole page as one Form XObject, which Edit a PDF selected as one page-sized group. Ungroup (right-click ▸ Ungroup, or double-click the group; image_edit action ungroup) writes the form's content in place of its Do, inside q, its /Matrix and its /BBox clip, renaming only resource names that clash with the page's; the form object and its other placements stay as they are, and the rendering is unchanged (pixel-identical in the engine test). One level at a time: nested forms become groups of their own. One undo step. Refused with the reason: transparency groups whose blending would change (knockout, transparency inside or applied to the group, another blending space), forms in the structure tree (/StructParent(s)), other Default colour spaces, content the parser had to skip or that restores or closes more than it opens. Composite fonts with /ToUnicode /Identity-H (a name, not a CMap) now read codes as Unicode, so such text can be edited and found. Still missing: editing inside a form in place as Acrobat does (without ungrouping), and selecting individual vector paths (edit.vector-edit)." + [[feature]] id = "edit.image-extract" title = "Save an image to a file" @@ -2812,8 +2824,8 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] -notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] id = "organize.duplicate-pages" @@ -2833,11 +2845,11 @@ title = "Reverse page order" area = "D" tier = "P2" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.reverse"] +tools = ["page_reverse"] +evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] +notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu" [[feature]] id = "organize.cut-copy-paste-pages" @@ -3812,8 +3824,8 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] -notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel" +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] id = "comment.record-audio" @@ -4160,8 +4172,8 @@ milestone = "M5" status = "shipped" commands = ["comment.export"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "" +evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"] +notes = "XFDF exports retain tabs and line breaks in comment identities and metadata." [[feature]] id = "comment.export-selected" @@ -4692,8 +4704,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data"] tools = ["doc_export_data"] -evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)" +evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] +notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references." [[feature]] id = "form.data-xml-csv-txt" @@ -4704,8 +4716,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited" +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. XML/XFDF exports retain tabs and line breaks in field identities through character references." [[feature]] id = "form.merge-to-spreadsheet" @@ -5088,8 +5100,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" @@ -5728,8 +5740,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] -notes = "with pop-ups; fields lose all widgets" +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets" [[feature]] id = "protect.redact-struct-tree" diff --git a/vendor/README.md b/vendor/README.md index 62be4cad9bd..649c4750729 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,7 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing | 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 ş; (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 | (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (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`; (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` | +| 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 ş; (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) 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, MuPDF and pdf.js do (`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 | (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (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`; (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) `text::tests::identity_to_unicode_names_give_text` | | 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 ş; (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) 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); (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) | (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (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`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | 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 ş; (27) 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; (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) | (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`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (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`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | | 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) 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`; (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) | (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) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable` | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index dd2edf241d6..56bb04f24c0 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -22,6 +22,25 @@ use std::sync::{Arc, LazyLock, OnceLock}; /// A storage for the components of colors. pub type ColorComponents = SmallVec<[f32; 4]>; +std::thread_local! { + // PdfCraft patch (34): see `tint_transform_runs`. + static TINT_TRANSFORM_RUNS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN +/// tint transform, for diagnostics and tests. An image converted through patch (28)'s lookup +/// table runs it once per possible sample value, not once per pixel. Per thread, so a caller +/// counting its own render is not disturbed by renders on other threads. +pub fn tint_transform_runs() -> u64 { + TINT_TRANSFORM_RUNS.with(core::cell::Cell::get) +} + +/// PdfCraft patch (34): count `colours` runs of a tint transform (called once per batch). +fn count_tint_transform_runs(colours: usize) { + let colours = u64::try_from(colours).unwrap_or(u64::MAX); + TINT_TRANSFORM_RUNS.with(|runs| runs.set(runs.get().saturating_add(colours))); +} + /// An RGB color with an alpha channel. #[derive(Debug, Copy, Clone)] pub struct AlphaColor { @@ -857,6 +876,7 @@ impl Separation { impl ToRgb for Separation { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + count_tint_transform_runs(input.len()); let evaluated = input .iter() .flat_map(|n| { @@ -911,6 +931,8 @@ impl DeviceN { impl ToRgb for DeviceN { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + let colours = input.len().checked_div(usize::from(self.num_components)); + count_tint_transform_runs(colours.unwrap_or(0)); let evaluated = input .chunks_exact(self.num_components as usize) .flat_map(|n| { diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index b99dcbc116c..53611d8cfef 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -45,6 +45,11 @@ pub(crate) struct Type0Font { /// PdfCraft patch: whether the CIDs belong to an Adobe character collection (Japan1, GB1, /// CNS1, Korea1), whose glyph numbering no substitute other than a CID-keyed CFF font shares. adobe_collection: bool, + /// PdfCraft patch: `/ToUnicode /Identity-H` (a name, not a CMap stream), which CAD exporters + /// such as WSCAD write for fonts whose two-byte codes are UTF-16 code units. Acrobat, MuPDF + /// and pdf.js read the code as the Unicode value; the identity CMap the name resolves to + /// maps codes to CIDs only and gave no text at all. + identity_to_unicode: bool, } impl Type0Font { @@ -117,6 +122,9 @@ impl Type0Font { let cache_key = dict.cache_key(); let mut to_unicode = read_to_unicode(dict, cmap_resolver); + let identity_to_unicode = dict + .get::>(TO_UNICODE) + .is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V")); let mut to_unicode_is_cid_indexed = false; // If there is no ToUnicode map, try to get the UCS2 CMap. @@ -162,6 +170,7 @@ impl Type0Font { fallback, to_unicode_is_cid_indexed, adobe_collection, + identity_to_unicode, }) } @@ -431,6 +440,12 @@ impl Type0Font { } pub(crate) fn char_code_to_unicode(&self, code: u32) -> Option { + if self.identity_to_unicode { + // Controls and lone surrogates are not text. + return char::from_u32(code) + .filter(|c| !c.is_control()) + .map(BfString::Char); + } if let Some(to_unicode) = &self.to_unicode { let key = if self.to_unicode_is_cid_indexed { self.code_to_cid(code).unwrap_or(0)