diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index a973e37f..70f0f71e 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -25,5 +25,5 @@ jobs: with: components: rustfmt, clippy - run: cargo fmt --check - - run: cargo clippy -p craft-i18n --all-targets --locked -- -D warnings - - run: cargo test -p craft-i18n --locked -- --nocapture + - run: cargo clippy -p craft-i18n --all-targets --features collate --locked -- -D warnings + - run: cargo test -p craft-i18n --features collate --locked -- --nocapture diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml new file mode 100644 index 00000000..4dc3fe18 --- /dev/null +++ b/.github/workflows/keymap.yml @@ -0,0 +1,92 @@ +name: Keymap + +on: + push: + branches: [main] + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + pull_request: + paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: keymap-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + timeout-minutes: 15 + name: Keymap (Linux) + runs-on: ubuntu-latest + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Checkout test fonts + uses: actions/checkout@v4 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: .test-inputs/craft-fonts + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-linux + - run: cargo fmt -p craft-keymap --check + - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings + # `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU + # to run, so the test steps leave them out. + - run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings + # Doctests run once, in the `ui` step. + - name: Test core + run: cargo test -p craft-keymap --no-default-features --lib --tests --locked + - name: Test egui dispatch and the shortcuts dialog + run: cargo test -p craft-keymap --features ui --locked + native: + timeout-minutes: 15 + name: Keymap (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Checkout test fonts + uses: actions/checkout@v4 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: .test-inputs/craft-fonts + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-${{ matrix.os }} + - run: cargo test -p craft-keymap --features ui --locked + wasm: + timeout-minutes: 10 + name: Keymap WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-keymap-wasm + - run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 34cd2ee2..8a32a632 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ author, source and license. Library code and original assets are under the repos | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | | `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) | +| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | Original assets are covered by the repository licence. @@ -32,10 +33,11 @@ The vendored source patch and per-file hashes are recorded in `vendor/krilla/REA ## Assets retained with the optional PDF reader -The shared reader centralizes the four existing patched Hayro source trees from PdfCraft. +The shared reader centralizes the five patched Hayro source trees from PdfCraft and the +patched crates.io `hayro-postscript` 0.1.0 scanner, which bundles no assets. The standard-14 fonts, CMYK profile and generated metrics listed below remain byte-identical -in `vendor/hayro-interpret`; no application document fonts are added. CMaps remain a pinned -registry dependency. The removed upstream LAB.icc profile is not restored. Source hashes +in `vendor/hayro-interpret`, and the CMaps in `vendor/hayro-cmap/assets`; no application +document fonts are added. The removed upstream LAB.icc profile is not restored. Source hashes and the exact transfer revision are recorded in each vendored crate's SOURCE.json. | Crate / file | Author | Source | Licence | SHA-256 | Use | @@ -55,5 +57,5 @@ and the exact transfer revision are recorded in each vendored crate's SOURCE.jso | `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | | `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | | `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | -| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | | `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | diff --git a/Cargo.lock b/Cargo.lock index e7b6efb5..bd648825 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -35,6 +37,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -571,6 +574,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -733,14 +745,59 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", + "skera", + "skrifa 0.48.0", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" dependencies = [ - "intl_pluralrules", + "fixed_decimal", + "icu_collator", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "log", + "objc2-foundation 0.3.2", "sys-locale", - "unic-langid", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-keymap" +version = "0.1.0" +dependencies = [ + "craft-i18n", + "craft-settings", + "craft-ui", + "egui", + "egui_kittest", + "image", + "serde", + "serde_json", + "sha2", + "tempfile", ] [[package]] @@ -1055,6 +1112,7 @@ source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d09 dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -1103,6 +1161,7 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -1189,8 +1248,21 @@ version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", + "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1252,6 +1324,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.37.0" @@ -1262,13 +1345,14 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust", "log", "nohash-hasher", "parking_lot", "profiling", "self_cell", + "serde", "skrifa 0.44.0", "smallvec", "unicode-bidi", @@ -1365,6 +1449,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1436,11 +1531,12 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1767,8 +1863,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" dependencies = [ "brotli", "hayro-postscript", @@ -1811,8 +1905,6 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" @@ -1983,6 +2075,24 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "serde", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1991,12 +2101,30 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2005,11 +2133,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2020,7 +2169,11 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", + "serde", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2030,6 +2183,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2041,6 +2220,7 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", + "serde", "zerotrie", "zerovec", ] @@ -2059,6 +2239,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2066,6 +2249,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2132,15 +2339,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2385,6 +2583,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3242,12 +3443,26 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3526,7 +3741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3537,10 +3752,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" +dependencies = [ + "bytemuck", + "foldhash", + "font-types 0.12.7", + "once_cell", + "yoke", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -4241,6 +4469,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531045338e36ad05b9652912a47ba38a935d0eaa0293c3e33d8112cfa414c14b" +dependencies = [ + "hashbrown 0.17.1", + "skrifa 0.48.0", + "thiserror 2.0.21", + "write-fonts 0.54.1", +] + [[package]] name = "skrifa" version = "0.42.1" @@ -4271,6 +4511,16 @@ dependencies = [ "read-fonts 0.44.0", ] +[[package]] +name = "skrifa" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034" +dependencies = [ + "bytemuck", + "read-fonts 0.45.1", +] + [[package]] name = "slab" version = "0.4.12" @@ -4291,6 +4541,9 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -4392,7 +4645,7 @@ dependencies = [ "kurbo 0.13.1", "rustc-hash 2.1.3", "skrifa 0.42.1", - "write-fonts", + "write-fonts 0.48.1", ] [[package]] @@ -4857,24 +5110,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4984,6 +5219,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5785,11 +6026,31 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write-fonts" +version = "0.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55716cfa7e8bcdb90289e07dfac478ccb2e3ec0f9a604fe682c7a03a7984d705" +dependencies = [ + "font-types 0.12.7", + "log", + "read-fonts 0.45.1", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x509-cert" @@ -6030,8 +6291,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 39f58e79..817ce524 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*"] -exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2"] +exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2", "vendor/hayro-cmap", "vendor/hayro-postscript"] [workspace.package] version = "0.1.0" @@ -24,7 +24,9 @@ thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } +craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } +craft-keymap = { path = "crates/keymap" } craft-media = { path = "crates/media" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } @@ -54,6 +56,8 @@ hayro = { path = "vendor/hayro" } hayro-interpret = { path = "vendor/hayro-interpret" } hayro-syntax = { path = "vendor/hayro-syntax" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } +hayro-cmap = { path = "vendor/hayro-cmap" } +hayro-postscript = { path = "vendor/hayro-postscript" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } diff --git a/NOTICE b/NOTICE index 38a65fcf..78e88b32 100644 --- a/NOTICE +++ b/NOTICE @@ -96,15 +96,18 @@ and vendor/krilla/SOURCE.json. Its upstream MIT/Apache-2.0 licences and CC0 prof Optional PDF reader -Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 -(https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their upstream licence files. -These patched sources are transferred from PdfCraft revision -7437bde378f43856eada40af25b983812e615e8a (https://github.com/storytold/pdfcraft). +Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 +and hayro-postscript 0.1.0 (https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their +upstream licence files. The first five are patched sources transferred from PdfCraft +(https://github.com/storytold/pdfcraft): hayro-cmap from revision +a2ec27b4775f53c40cad3456936d9406611e3a8d, the others from revision +7437bde378f43856eada40af25b983812e615e8a. hayro-postscript is the crates.io 0.1.0 package +(upstream revision a2354aebaf86f5ab13e5fd6b6b0521306488bf9d) with one local patch. See vendor/README.craft-reader.md and each crate's SOURCE.json for provenance and local changes. The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause), Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause). Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, -licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; +vendor/hayro-cmap/assets, licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; applications retain their own document-font selection and licensing policy. Platform lifecycle source provenance diff --git a/README.md b/README.md index 2dc92f8a..11a13d3f 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,17 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Vec { + println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR"); + let Some(dir) = std::env::var_os("CRAFT_I18N_DIR").filter(|dir| !dir.is_empty()) else { return Vec::new() }; + let locales = PathBuf::from(dir).join("locales"); + println!("cargo::rerun-if-changed={}", locales.display()); + let mut paths: Vec = std::fs::read_dir(&locales) + .into_iter() + .flatten() + .flatten() + .flat_map(|locale| ["common.tsv".to_owned(), format!("{app}.tsv")].map(|file| locale.path().join(file))) + .filter(|path| path.is_file()) + .collect(); + paths.sort(); + paths +} +``` + +The crate: + +```rust +use craft_fonts_build::EmbeddedFace; + +/// craft-fonts faces; empty unless built with `CRAFT_FONTS_DIR`. +pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +``` + +`Embed::new()` selects every face except the sources of the interface faces (`role` `ui`, see +below), and `Embed::documents(&["Noto Sans", …])` only the families it names (see +[Document fonts](#document-fonts)). The builder narrows the set: + +- `scripts(&["Jpan", "Arab", "Hant"])` keeps faces with any of these script tags. A tag matches + itself and its region forms (`Hant` matches `Hant-HK`). With `CRAFT_FONTS_REQUIRED=1`, each tag + must match at least one face. +- `filter(|entry| …)` keeps faces by any manifest field (family, style, licence, index). +- `for_ui()` keeps CJK faces only as the interface faces (see below). +- `subset_to(catalogs)` (feature `subset`) cuts the interface faces to the catalogs' characters; + it implies `for_ui()`. +- `check_text(paths)` warns about characters in these files that no embedded face covers. +- `embedded_elsewhere(&["Noto Sans Math", …])` names families another table of the app embeds, + so `check_text` and the catalog check count their characters as covered. +- `dir(path)` and `required(bool)` override the two environment variables. + +`write` returns the embedded `ManifestEntry`s, for build-time checks such as "the interface face +is present". The generated file is a slice expression of `EmbeddedFace { family, style, scripts, +index, bytes }` literals; any struct with those fields and that name in scope works too. +`EmbeddedFace` itself has `name()`, `has_script(tag)`, `is_cjk()` and `weight()`. + +## Document fonts + +An app that draws document text (titles, layouts, vector text) embeds the families its documents +use, and only those, in a table of its own: + +```rust +let embedded = craft_fonts_build::Embed::documents(&["Noto Sans", "Shippori Mincho"]) + // The interface table holds the CJK cuts and the symbol faces. + .embedded_elsewhere(&["Noto Sans Math", "Noto Sans Symbols 2"]) + .write("document_fonts.rs"); +``` + +Each named family brings all its styles, as whole files: a CJK document face takes 5–17 MB, so a +release embeds exactly the faces the app's documents need. A family named here is embedded even +when it is also an interface face's source (`BIZ UDPGothic` brings its Regular too). A name the +manifest lacks is an `Error::UnknownFamilies` listing the manifest's families: a build error with +`CRAFT_FONTS_REQUIRED=1`, and a `cargo::warning` with an empty table otherwise, as for any +checkout that can't be used. `scripts` and `filter` narrow the families further, for example to +their Regular faces. + +## Interface faces + +The full CJK faces take 5–17 MB each. For the interface, craft-fonts marks one Regular TrueType +face per CJK script with the role `ui`: `Noto Sans SC UI` (`Hans`), `Noto Sans TC UI` (`Hant`), +`Noto Sans HK UI` (`Hant-HK`), `Noto Sans KR UI` (`Kore`) and BIZ UDPGothic Regular (`Jpan`). The +app's build cuts them to the characters its translation catalogs use; for FilmCraft's catalogs +the five cuts take about 3 MB together. + +- `for_ui()` drops every face with a CJK script tag (`Hans`, `Hant`, `Hant-HK`, `Jpan`, `Kore`, + `Hani`) that is not an interface face, and keeps the rest. Document text embeds the full faces + it names with `Embed::documents` instead. Without `for_ui()` or `subset_to`, the interface + faces' sources are left out unless `Embed::documents` names their family. +- `subset_to(catalogs)` cuts each interface face to the characters the catalogs use from U+2E80 up + (CJK and later blocks, without emoji, variation selectors and tags), plus CJK punctuation + (U+3000–303F), kana (U+3040–30FF, U+31F0–31FF) and full-width forms (U+FF00–FFEF), and keeps + every layout feature, so vertical forms (`vert`) still work. Every face gets the characters of + every catalog, so a Japanese label in a Chinese catalog still draws. The cuts are written to + `OUT_DIR/craft-fonts-ui/` and embedded from there; the same inputs give the same bytes, for + reproducible releases. Cutting all five faces takes well under a second. +- Without `subset_to`, or with an empty catalog list, `for_ui()` leaves the interface faces out + with a `cargo::warning`: a full CJK face is never embedded for the interface by accident. A + checkout whose manifest has no interface face is a `cargo::warning` too. With `CRAFT_FONTS_REQUIRED=1` that + makes `scripts(&["Hant"])` an error; with catalogs, an interface face satisfies it. +- An interface face with CFF outlines is an error (`Error::NotTrueType`), even when not required: + the subsetter cuts TrueType (`glyf`) faces correctly, but not CFF ones. + +The catalogs come from craft-i18n. An app's build reads them from the checkout `CRAFT_I18N_DIR` +names (craft-i18n's `docs/integration.md`); the fonts build script reads the same variable, as in +the example above. Catalogs kept in the app's own repository are passed by path, relative to the +package directory. + +A character the faces lack still shows: craft-ui's system font provider draws it with an installed +font. The build says so: for each catalog, and each file passed to `check_text`, it prints one +`cargo::warning` with the count and the first few characters from U+2E80 up that no embedded face +covers. Since the cuts hold every catalog character their sources have, these are the characters +the sources lack. Faces the app embeds in another table count as covering their characters when +named with `embedded_elsewhere`; they are read whole from the checkout, so a face cut elsewhere is +exact only for the catalogs it was cut to. It also reruns the build script when a file changes. + +## The manifest + +One face per line, eight or nine fields separated by `" | "`; craft-fonts' `docs/integration.md` +defines them. Lines starting with `#` are comments. A file field ending in `#` names face `n` of +a font collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. The ninth +field, `role`, is `ui` on interface faces and absent elsewhere. Fields after the ninth are ignored. diff --git a/crates/fonts-build/src/cmap.rs b/crates/fonts-build/src/cmap.rs new file mode 100644 index 00000000..b4dd4a7f --- /dev/null +++ b/crates/fonts-build/src/cmap.rs @@ -0,0 +1,118 @@ +//! Just enough of an OpenType reader to ask which characters a face maps: the `cmap` table's +//! Unicode subtables in formats 4 and 12, in a single font or one face of a collection. + +/// The Unicode character maps of one face. +pub(crate) struct Cmap<'a> { + subtables: Vec<&'a [u8]>, +} + +impl<'a> Cmap<'a> { + /// The cmap of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when the + /// file isn't a font or has no Unicode subtable in format 4 or 12. + pub(crate) fn read(font: &'a [u8], index: u32) -> Option { + let face = if font.get(0..4)? == b"ttcf" { + if index >= u32_at(font, 8)? { + return None; + } + usize::try_from(u32_at(font, 12 + 4 * usize::try_from(index).ok()?)?).ok()? + } else { + 0 + }; + let tables = usize::from(u16_at(font, face + 4)?); + let record = (0..tables).map(|i| face + 12 + 16 * i).find(|&r| font.get(r..r + 4) == Some(b"cmap"))?; + let cmap = slice(font, u32_at(font, record + 8)?, u32_at(font, record + 12)?)?; + let mut subtables = Vec::new(); + for i in 0..usize::from(u16_at(cmap, 2)?) { + let (platform, encoding) = (u16_at(cmap, 4 + 8 * i)?, u16_at(cmap, 6 + 8 * i)?); + // Unicode platform, or Windows Unicode BMP (1) and full repertoire (10). + if platform != 0 && !(platform == 3 && matches!(encoding, 1 | 10)) { + continue; + } + let start = usize::try_from(u32_at(cmap, 8 + 8 * i)?).ok()?; + let rest = cmap.get(start..)?; + // Bound each subtable by its own length so that reads can't stray into the next one. + // Some large CJK fonts declare a format 4 length past the end of the table, so that + // one is clamped; a format 12 length that doesn't fit is skipped. + let len = match u16_at(rest, 0) { + Some(4) => u16_at(rest, 2).map(|len| usize::from(len).min(rest.len())), + Some(12) => u32_at(rest, 4).and_then(|len| usize::try_from(len).ok()), + _ => None, + }; + if let Some(subtable) = len.and_then(|len| rest.get(..len)) { + subtables.push(subtable); + } + } + (!subtables.is_empty()).then_some(Self { subtables }) + } + + /// Whether any Unicode subtable maps `c` to a glyph other than `.notdef`. + pub(crate) fn covers(&self, c: char) -> bool { + self.subtables.iter().any(|t| glyph(t, u32::from(c)).is_some_and(|g| g != 0)) + } +} + +fn glyph(subtable: &[u8], c: u32) -> Option { + match u16_at(subtable, 0)? { + 4 => format4(subtable, u16::try_from(c).ok()?), + 12 => format12(subtable, c), + _ => None, + } +} + +fn format4(t: &[u8], c: u16) -> Option { + let seg_x2 = usize::from(u16_at(t, 6)?); + let (ends, starts, deltas, range_offsets) = (14, 16 + seg_x2, 16 + 2 * seg_x2, 16 + 3 * seg_x2); + // End codes are sorted: the first segment ending at or after `c` is the only candidate. + let segments = seg_x2 / 2; + let i = partition_point(segments, |i| u16_at(t, ends + 2 * i).is_some_and(|end| end < c)); + if i == segments { + return None; + } + let start = u16_at(t, starts + 2 * i)?; + if c < start { + return None; + } + let delta = u16_at(t, deltas + 2 * i)?; + let range_offset_at = range_offsets + 2 * i; + let range_offset = usize::from(u16_at(t, range_offset_at)?); + if range_offset == 0 { + return Some(u32::from(c.wrapping_add(delta))); + } + let g = u16_at(t, range_offset_at + range_offset + 2 * usize::from(c - start))?; + Some(if g == 0 { 0 } else { u32::from(g.wrapping_add(delta)) }) +} + +fn format12(t: &[u8], c: u32) -> Option { + let groups = usize::try_from(u32_at(t, 12)?).ok()?; + // Groups are sorted by start code and don't overlap. + let i = partition_point(groups, |i| u32_at(t, 16 + 12 * i + 4).is_some_and(|end| end < c)); + if i == groups { + return None; + } + let g = 16 + 12 * i; + let (start, end, glyph) = (u32_at(t, g)?, u32_at(t, g + 4)?, u32_at(t, g + 8)?); + (start..=end).contains(&c).then(|| glyph.wrapping_add(c - start)) +} + +/// The first index in `0..len` for which `before` is false, given that it is true for a prefix. +fn partition_point(len: usize, before: impl Fn(usize) -> bool) -> usize { + let (mut lo, mut hi) = (0, len); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if before(mid) { lo = mid + 1 } else { hi = mid } + } + lo +} + +fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> { + let offset = usize::try_from(offset).ok()?; + data.get(offset..offset.checked_add(usize::try_from(len).ok()?)?) +} + +fn u16_at(data: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(data.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(data: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs new file mode 100644 index 00000000..69fc5657 --- /dev/null +++ b/crates/fonts-build/src/embed.rs @@ -0,0 +1,516 @@ +//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. + +#[cfg(feature = "subset")] +use std::collections::BTreeSet; +use std::collections::HashSet; +use std::ffi::OsString; +use std::fmt::{self, Write as _}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::cmap::Cmap; +use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches}; + +const DIR_VAR: &str = "CRAFT_FONTS_DIR"; +const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED"; +const EMPTY_TABLE: &str = "&[]\n"; + +/// Whether the build script is building for wasm32 (web builds usually embed fewer faces). +pub fn target_is_wasm() -> bool { + std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32") +} + +type Filter = Box bool>; + +/// Which faces to embed, and from where. [`Embed::write`] does the work. +#[derive(Default)] +pub struct Embed { + scripts: Option>, + filter: Option, + dir: Option, + required: Option, + ui: bool, + catalogs: Option>, + texts: Vec, + families: Option>, + elsewhere: Vec, +} + +/// A selected face and the file the table embeds it from. +struct Face { + entry: ManifestEntry, + path: PathBuf, + index: u32, + /// An interface face to cut to the catalogs' characters before it is embedded. + #[cfg_attr(not(feature = "subset"), expect(dead_code))] + cut: bool, +} + +impl Embed { + /// Every face except the interface faces' sources (`role` `ui`), from `CRAFT_FONTS_DIR`, + /// required when `CRAFT_FONTS_REQUIRED=1`. Narrow it with [`Embed::scripts`] and + /// [`Embed::filter`]; a table of document fonts starts from [`Embed::documents`] instead. + pub fn new() -> Self { + Self::default() + } + + /// Every face of these families, whole, and nothing else: an app's document fonts. A family + /// is named as the manifest spells it (`"Noto Sans"`, `"Shippori Mincho"`) and contributes all + /// its styles, interface faces included (`"BIZ UDPGothic"` brings its Regular as well). + /// A name the manifest lacks is an [`Error::UnknownFamilies`], so the build fails with + /// `CRAFT_FONTS_REQUIRED=1` and warns and embeds nothing otherwise. Narrow it further with + /// [`Embed::scripts`] and [`Embed::filter`]. + pub fn documents(families: &[&str]) -> Self { + Self { families: Some(families.iter().map(|f| (*f).to_owned()).collect()), ..Self::default() } + } + + /// Embed only faces with at least one of these script tags (see + /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED=1`, each tag must match a face. + pub fn scripts(mut self, tags: &[&str]) -> Self { + self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect()); + self + } + + /// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]). + pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self { + self.filter = Some(Box::new(keep)); + self + } + + /// Embed faces for an app's interface: CJK faces only as the interface faces (`role` `ui` in + /// the manifest), cut to the characters of the catalogs given to `Embed::subset_to`. Other + /// CJK faces are dropped and the rest are kept. Without catalogs the interface faces are left + /// out too, with a `cargo::warning`, so a full CJK face is never embedded for the interface. + /// A manifest with no interface face is also a `cargo::warning` when that drops a CJK face. + /// Combines with [`Embed::scripts`] and [`Embed::filter`]; with `CRAFT_FONTS_REQUIRED=1`, an + /// interface face satisfies its script tags. + pub fn for_ui(mut self) -> Self { + self.ui = true; + self + } + + /// Cut the interface faces to the characters these translation catalogs use, and embed the + /// cut faces, written to `OUT_DIR`. Implies [`Embed::for_ui`]. An empty list counts as no + /// catalogs: the interface faces are left out, with a `cargo::warning`. + /// + /// The cut keeps the catalogs' characters from U+2E80 up (CJK and later blocks, without emoji, + /// variation selectors and tags), plus CJK punctuation (U+3000–303F), kana (U+3040–30FF, + /// U+31F0–31FF) and full-width forms (U+FF00–FFEF), with every layout feature. The same inputs + /// give the same bytes. A face with CFF outlines is an [`Error::NotTrueType`]. + /// + /// The catalogs are also checked as by [`Embed::check_text`]. Relative paths are under the + /// package directory; an unreadable catalog is a `cargo::warning`. + #[cfg(feature = "subset")] + pub fn subset_to>(mut self, catalogs: impl IntoIterator) -> Self { + self.ui = true; + self.catalogs.get_or_insert_default().extend(catalogs.into_iter().map(Into::into)); + self + } + + /// Check that the embedded faces cover these text files, such as translation catalogs: one + /// `cargo::warning` per file that has characters from U+2E80 up (CJK and later blocks, without + /// emoji and variation selectors) that no embedded face maps. Those characters fall back to + /// system fonts at run time. Relative paths are under the package directory. The check runs + /// only when a checkout was read. + pub fn check_text>(mut self, paths: impl IntoIterator) -> Self { + self.texts.extend(paths.into_iter().map(Into::into)); + self + } + + /// Families the app embeds in another table, such as its interface faces next to its document + /// fonts: [`Embed::check_text`] and the catalog check count their characters as covered. Each + /// is read from the checkout whole, so for a face cut elsewhere this is exact only for the + /// catalogs it was cut to. A name the manifest lacks is a `cargo::warning`. + pub fn embedded_elsewhere(mut self, families: &[&str]) -> Self { + self.elsewhere.extend(families.iter().map(|f| (*f).to_owned())); + self + } + + /// Read this checkout instead of `CRAFT_FONTS_DIR`. + pub fn dir(mut self, dir: impl Into) -> Self { + self.dir = Some(dir.into()); + self + } + + /// Override `CRAFT_FONTS_REQUIRED` (required only when it is exactly `1`). + pub fn required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } + + /// Write the face table to `out_file` and print the `cargo::` lines; returns the faces + /// embedded. + /// + /// Without a checkout the table is empty. A checkout that can't be read is a + /// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written + /// in every case, so on an error the build fails only on the message the caller prints. + pub fn write(&self, out_file: impl AsRef) -> Result, Error> { + let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") }; + let mut cargo = Vec::new(); + let result = self.run(&env, out_file.as_ref(), &mut cargo); + for line in cargo { + println!("{line}"); + } + result + } + + pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec) -> Result, Error> { + cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}")); + cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}")); + for text in self.checked_files() { + cargo.push(format!("cargo::rerun-if-changed={}", text.display())); + } + let out = match (out_file.is_absolute(), &env.out_dir) { + (true, _) => out_file.to_path_buf(), + (false, Some(out_dir)) => Path::new(out_dir).join(out_file), + (false, None) => return Err(Error::NoOutDir), + }; + let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| v == "1")); + let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from)); + + let selected = match &dir { + None => Err(Error::NotSet), + Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }), + }; + let mut selected = selected; + if let Ok(faces) = &mut selected { + // A face that can't be cut is a broken manifest line: an error even when not required. + if let Err(e) = self.cut(faces, out.parent().unwrap_or(Path::new(""))) { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + if let Some(dir) = &dir { + self.check_coverage(faces, dir, cargo); + } + } + let tabled = selected.and_then(|faces| Ok((table(&faces)?, faces.into_iter().map(|face| face.entry).collect()))); + let (table, faces) = match tabled { + Ok(tabled) => tabled, + Err(e) if required => { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()), + Err(e) => { + cargo.push(format!("cargo::warning=building without craft-fonts: {e}")); + (EMPTY_TABLE.to_owned(), Vec::new()) + } + }; + write(&out, &table)?; + Ok(faces) + } + + /// The wanted manifest entries with their canonical file paths. + fn select(&self, dir: &Path, cargo: &mut Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?; + // Cargo treats a missing watched file as changed, which would rerun the build every time. + cargo.push(format!("cargo::rerun-if-changed={}", manifest.display())); + let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?; + if let Some(families) = &self.families { + let unknown: Vec = families.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)).cloned().collect(); + if !unknown.is_empty() { + let mut known: Vec = Vec::new(); + for entry in &entries { + if !known.contains(&entry.family) { + known.push(entry.family.clone()); + } + } + return Err(Error::UnknownFamilies { manifest, families: unknown, known }); + } + } + let has_ui = entries.iter().any(ManifestEntry::is_ui); + let no_catalogs = self.catalogs.as_ref().is_none_or(Vec::is_empty); + let mut faces = Vec::new(); + let mut uncut = Vec::new(); + let mut dropped_cjk = false; + for entry in entries { + // Interface faces are the sources of the cuts: embedded whole only when named. + let named = match &self.families { + Some(families) => families.contains(&entry.family), + None => self.ui || !entry.is_ui(), + }; + if !named { + continue; + } + let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); + let ui_dropped = self.ui && entry.is_cjk() && !entry.is_ui(); + if !wanted || ui_dropped || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + dropped_cjk |= wanted && ui_dropped; + continue; + } + let cut = self.ui && entry.is_ui(); + // An empty list would cut the faces to kana and punctuation, so it counts as none. + if cut && no_catalogs { + uncut.push(format!("{} {}", entry.family, entry.style)); + continue; + } + let file = dir.join(&entry.file); + // `include_bytes!` resolves relative paths from the including source file. + let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?; + cargo.push(format!("cargo::rerun-if-changed={}", path.display())); + let index = entry.index; + faces.push(Face { entry, path, index, cut }); + } + if !uncut.is_empty() { + let why = if self.catalogs.is_some() { "Embed::subset_to was given no translation catalogs" } else { "no translation catalogs" }; + cargo.push(format!( + "cargo::warning=craft-fonts: not embedding the interface faces {}: {why}; pass the catalogs to \ + Embed::subset_to (feature `subset`) to embed them cut to the catalogs' characters", + uncut.join(", ") + )); + } + if dropped_cjk && !has_ui { + cargo.push(format!( + "cargo::warning=craft-fonts: not embedding any CJK face for the interface: {} has no interface face \ + (role `ui`); use a craft-fonts checkout that has them", + manifest.display() + )); + } + Ok(faces) + } + + /// Replace each interface face with its cut, written to `out_dir`. + #[cfg(feature = "subset")] + fn cut(&self, faces: &mut [Face], out_dir: &Path) -> Result<(), Error> { + let Some(catalogs) = &self.catalogs else { return Ok(()) }; + // `select` leaves the faces to cut out when there are no catalogs. + if !faces.iter().any(|face| face.cut) { + return Ok(()); + } + let mut chars = BTreeSet::new(); + // An unreadable catalog adds nothing here; `check_coverage` reports it. + for text in catalogs.iter().filter_map(|path| std::fs::read_to_string(path).ok()) { + chars.extend(text.chars().filter(|&c| needs_glyph(c))); + } + for (start, end) in UI_RANGES { + chars.extend(start..=end); + } + let dir = out_dir.join("craft-fonts-ui"); + std::fs::create_dir_all(&dir).map_err(|source| Error::Write { path: dir.clone(), source })?; + for face in faces.iter_mut().filter(|face| face.cut) { + let source = std::fs::read(&face.path).map_err(|source| Error::Read { path: face.path.clone(), source })?; + let bytes = crate::subset::subset(&source, face.index, &chars).map_err(|failure| match failure { + crate::subset::Failure::NotTrueType => Error::NotTrueType { path: face.path.clone() }, + crate::subset::Failure::Other(reason) => Error::Subset { path: face.path.clone(), reason }, + })?; + let name: String = + format!("{}-{}", face.entry.family, face.entry.style).chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + let out = dir.join(format!("{name}.ttf")); + // Rewriting identical bytes would make Cargo rebuild the crate that embeds them. + if std::fs::read(&out).ok().as_deref() != Some(&bytes[..]) { + std::fs::write(&out, &bytes).map_err(|source| Error::Write { path: out.clone(), source })?; + } + face.path = out; + face.index = 0; + } + Ok(()) + } + + /// Without the subsetter there are no catalogs, so [`Embed::select`] left out every face to cut. + #[cfg(not(feature = "subset"))] + fn cut(&self, _faces: &mut [Face], _out_dir: &Path) -> Result<(), Error> { + Ok(()) + } + + /// The catalogs, then the other [`Embed::check_text`] files. + fn checked_files(&self) -> Vec<&PathBuf> { + let mut files: Vec<&PathBuf> = self.catalogs.iter().flatten().collect(); + for text in &self.texts { + if !files.contains(&text) { + files.push(text); + } + } + files + } + + /// The manifest entries of the [`Embed::embedded_elsewhere`] families, with a + /// `cargo::warning` for a family the manifest lacks. + fn elsewhere(&self, dir: &Path, cargo: &mut Vec) -> Vec { + if self.elsewhere.is_empty() { + return Vec::new(); + } + let manifest = dir.join("fonts/manifest.txt"); + // `select` read and parsed this manifest already. + let entries = std::fs::read_to_string(&manifest).ok().and_then(|text| parse_manifest(&text).ok()).unwrap_or_default(); + for family in self.elsewhere.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)) { + cargo.push(format!("cargo::warning=craft-fonts: embedded_elsewhere names {family}, which {} lacks", manifest.display())); + } + entries.into_iter().filter(|e| self.elsewhere.contains(&e.family)).collect() + } + + /// Warn about each catalog's and [`Embed::check_text`] file's characters that neither an + /// embedded face nor an [`Embed::embedded_elsewhere`] face covers. + fn check_coverage(&self, faces: &[Face], dir: &Path, cargo: &mut Vec) { + let files = self.checked_files(); + if files.is_empty() { + return; + } + let elsewhere = self.elsewhere(dir, cargo); + let fonts: Vec<(&ManifestEntry, u32, Option>)> = faces + .iter() + .map(|face| (&face.entry, face.index, std::fs::read(&face.path).ok())) + .chain(elsewhere.iter().map(|entry| (entry, entry.index, std::fs::read(dir.join(&entry.file)).ok()))) + .collect(); + let mut cmaps = Vec::new(); + for (entry, index, bytes) in &fonts { + match bytes.as_deref().and_then(|bytes| Cmap::read(bytes, *index)) { + Some(cmap) => cmaps.push(cmap), + None => cargo + .push(format!("cargo::warning=craft-fonts: cannot read the character map of {} {} ({})", entry.family, entry.style, entry.file)), + } + } + for path in files { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) => { + cargo.push(format!("cargo::warning=craft-fonts: cannot check {}: {e}", path.display())); + continue; + } + }; + let mut seen = HashSet::new(); + let missing: Vec = text.chars().filter(|&c| needs_glyph(c) && seen.insert(c) && !cmaps.iter().any(|cmap| cmap.covers(c))).collect(); + if missing.is_empty() { + continue; + } + const SHOWN: usize = 8; + let shown: String = missing.iter().take(SHOWN).collect(); + let more = if missing.len() > SHOWN { " …" } else { "" }; + cargo.push(format!( + "cargo::warning=craft-fonts: {} has {} character{} no embedded face covers ({shown}{more}); system fonts will draw them.", + path.display(), + missing.len(), + if missing.len() == 1 { "" } else { "s" }, + )); + } + } + + fn check_required(&self, dir: &Path, faces: Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + if faces.is_empty() { + return Err(Error::NoFaces { manifest }); + } + let missing: Vec = self + .scripts + .iter() + .flatten() + .filter(|tag| !faces.iter().any(|face| face.entry.scripts.iter().any(|s| script_matches(s, tag)))) + .cloned() + .collect(); + if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) } + } +} + +/// Whether the interface faces need `c`: CJK and later blocks, except emoji and other pictographs +/// (the emoji faces draw them), variation selectors, tags and a byte order mark. +fn needs_glyph(c: char) -> bool { + c >= '\u{2E80}' && !matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{1F000}'..='\u{1FFFF}' | '\u{E0000}'..) +} + +/// Kept in every cut, whatever the catalogs use: CJK punctuation, kana and full-width forms. +#[cfg(feature = "subset")] +const UI_RANGES: [(char, char); 4] = [('\u{3000}', '\u{303F}'), ('\u{3040}', '\u{30FF}'), ('\u{31F0}', '\u{31FF}'), ('\u{FF00}', '\u{FFEF}')]; + +/// The environment a build script sees, separated out for tests. +pub(crate) struct Env { + pub dir: Option, + pub required: Option, + pub out_dir: Option, +} + +/// The generated source; `include_bytes!` takes a string, so a face under a path that isn't UTF-8 +/// is an error rather than a lossy path the consumer's build can't find. +fn table(faces: &[Face]) -> Result { + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for Face { entry, path, index, .. } in faces { + let file = path.to_str().ok_or_else(|| Error::NonUtf8Path { path: path.clone() })?; + let scripts: Vec = entry.scripts.iter().map(|s| format!("{s:?}")).collect(); + let _ = writeln!( + src, + " EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},", + entry.family, + entry.style, + scripts.join(", "), + index, + file, + ); + } + src.push_str("]\n"); + Ok(src) +} + +fn write(path: &Path, contents: &str) -> Result<(), Error> { + std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source }) +} + +/// Why the faces could not be embedded. Print it as `cargo::error={error}`. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// `CRAFT_FONTS_REQUIRED=1` but `CRAFT_FONTS_DIR` is unset or empty. + NotSet, + /// The manifest or a font file it lists can't be read. + Read { path: PathBuf, source: io::Error }, + /// The manifest has a malformed line. + Parse { manifest: PathBuf, error: ParseError }, + /// Required, but no face in the manifest was selected. + NoFaces { manifest: PathBuf }, + /// Required, but no face in the manifest has these requested script tags. + MissingScripts { manifest: PathBuf, scripts: Vec }, + /// The generated table can't be written. + Write { path: PathBuf, source: io::Error }, + /// `out_file` is relative and `OUT_DIR` is unset (not running as a build script). + NoOutDir, + /// An interface face has CFF outlines, which can't be cut correctly; it must be TrueType. + NotTrueType { path: PathBuf }, + /// An interface face can't be cut. + Subset { path: PathBuf, reason: String }, + /// [`Embed::documents`] names families the manifest lacks; `known` lists the manifest's. + UnknownFamilies { manifest: PathBuf, families: Vec, known: Vec }, + /// A face to embed has a path that isn't UTF-8, which `include_bytes!` can't name. + NonUtf8Path { path: PathBuf }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSet => { + write!(f, "{REQUIRED_VAR}=1 but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout") + } + Self::Read { path, source } => { + write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display()) + } + Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()), + Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR}=1 but no face in {} was selected", manifest.display()), + Self::MissingScripts { manifest, scripts } => { + write!(f, "{REQUIRED_VAR}=1 but {} has no face for {}", manifest.display(), scripts.join(", ")) + } + Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()), + Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"), + Self::NotTrueType { path } => write!( + f, + "{} has CFF outlines, which craft-fonts-build can't subset correctly: an interface face (role ui) must be a TrueType (glyf) font", + path.display() + ), + Self::Subset { path, reason } => write!(f, "cannot subset {}: {reason}", path.display()), + Self::UnknownFamilies { manifest, families, known } => write!( + f, + "{} has no family {}; Embed::documents names families as the manifest spells them: {}", + manifest.display(), + families.join(", "), + known.join(", ") + ), + Self::NonUtf8Path { path } => write!(f, "cannot embed {}: include_bytes! needs a UTF-8 path", path.display()), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } | Self::Write { source, .. } => Some(source), + Self::Parse { error, .. } => Some(error), + _ => None, + } + } +} diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs new file mode 100644 index 00000000..36ef4ab8 --- /dev/null +++ b/crates/fonts-build/src/lib.rs @@ -0,0 +1,125 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's +//! `build.rs`. +//! +//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for +//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead: +//! +//! - **Unset or empty:** the table is empty and the app uses its own and system fonts. +//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`. +//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table. +//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds; any other value is ignored): a missing or +//! unreadable checkout, no selected face, or a requested script without a face is an [`Error`]. +//! +//! Add this crate both as a build-dependency (for [`Embed`]) and as a dependency (for +//! [`EmbeddedFace`]). The build-dependency enables the `subset` feature for `Embed::subset_to`, +//! which cuts the CJK interface faces to the translation catalogs' characters; the dependency +//! leaves it off, so the subsetter is not part of the app. In `build.rs`'s `main`: +//! +//! ```ignore +//! println!("cargo::rerun-if-changed=build.rs"); +//! let wasm = craft_fonts_build::target_is_wasm(); +//! let catalogs: Vec = /* the app's translation catalogs */; +//! let embedded = craft_fonts_build::Embed::new() +//! // CJK faces only as the interface faces, cut to the characters the catalogs use. +//! .subset_to(catalogs) +//! // Web builds have a size budget: embed only the Japanese interface face there. +//! .filter(move |face| !wasm || face.has_script("Jpan")) +//! .write("craft_fonts.rs"); +//! if let Err(e) = embedded { +//! println!("cargo::error={e}"); +//! } +//! ``` +//! +//! and in the crate: +//! +//! ```ignore +//! use craft_fonts_build::EmbeddedFace; +//! +//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +//! ``` +//! +//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with +//! the same fields and that name in scope works too. +//! +//! An app's document fonts go in a table of their own that names its families, +//! `Embed::documents(&["Noto Sans", "Shippori Mincho"])`: nothing is embedded for documents +//! unless the app names it. + +mod cmap; +mod embed; +mod manifest; +#[cfg(feature = "subset")] +mod subset; +#[cfg(test)] +mod tests; + +pub use embed::{Embed, Error, target_is_wasm}; +pub use manifest::{CJK_SCRIPTS, ManifestEntry, ParseError, UI_ROLE, parse_manifest}; + +/// A craft-fonts face embedded by [`Embed`]: one line of `fonts/manifest.txt`. +#[derive(Clone, Copy)] +pub struct EmbeddedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// Style, e.g. `"Regular"` or `"Bold"`. + pub style: &'static str, + /// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a + /// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code). + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); `0` for a single font. + pub index: u32, + pub bytes: &'static [u8], +} + +impl EmbeddedFace { + /// `" \n\n\n

{title}

\n" + ); + for g in groups(km, text) { + let _ = writeln!(s, "

{}

\n", escape_html(&g.name)); + for (label, keys) in &g.rows { + let keys: String = keys.iter().map(|k| format!("{}", escape_html(k))).collect(); + let _ = writeln!(s, "", escape_html(label)); + } + s.push_str("
{}{keys}
\n"); + } + s.push_str("\n\n"); + s +} + +/// Plain text titled `title`: a line per group, then ` labelshortcuts` per command. +pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String { + let mut s = one_line(title); + s.push('\n'); + for g in groups(km, text) { + let _ = write!(s, "\n{}\n", one_line(&g.name)); + for (label, keys) in &g.rows { + let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", "))); + } + } + s +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs new file mode 100644 index 00000000..12dd0941 --- /dev/null +++ b/crates/keymap/src/ui/text.rs @@ -0,0 +1,388 @@ +//! The dialog's words: its own strings, command labels, menu path parts, key names and text +//! direction, all supplied by the app through [`Text`]. + +use std::borrow::Cow; + +use crate::chord::{Key, Platform, Trigger}; +use crate::keymap::Bindable; + +/// Where the dialog gets its words. Apps on craft-i18n pass [`I18nText`]; others implement the two +/// required methods with their own lookup. +pub trait Text { + /// The dialog's own strings, by English source text (listed in [`STRINGS`]). Placeholders + /// (`{chord}`, `{label}`, `{set}`, `{n}`) are filled after the lookup. + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str>; + /// A count's wording with `{n}` filled in. `one` and `other` are the English forms. + fn plural(&self, n: u64, one: &str, other: &str) -> String; + /// A command's label. + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + b.label.as_str().into() + } + /// A menu path part or a scope name. + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + s.into() + } + /// Extra lines for a command's tooltip. + fn hint(&self, _b: &Bindable) -> Option { + None + } + /// A key cap's name on `platform` (`Strg`, `Umschalt`); `None` keeps the crate's name. + fn key(&self, _k: Key, _p: Platform) -> Option { + None + } + /// Whether the interface language is written right to left. + fn rtl(&self) -> bool { + false + } + /// A label or message put into display order (the app's bidi reordering). Chord text is never + /// passed here. + fn visual(&self, s: &str) -> String { + s.to_string() + } +} + +/// [`Text`] over a craft-i18n catalog. +pub struct I18nText<'a> { + pub i18n: &'a craft_i18n::I18n, + pub lang: craft_i18n::Lang, +} + +impl Text for I18nText<'_> { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + self.i18n.trn(self.lang, n, one, other) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + self.i18n.tid(self.lang, &b.command.id, &b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, s).into() + } + + fn key(&self, k: Key, p: Platform) -> Option { + // Glyphs (⌫, ↑) need no translation. + if p.is_mac() && k.mac_symbol() != k.text_name() { + return None; + } + self.i18n.get(self.lang, "key", k.text_name()).map(str::to_string) + } + + fn rtl(&self) -> bool { + self.i18n.rtl(self.lang) + } +} + +/// English [`Text`]: every string as written. +pub struct English; + +impl Text for English { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + craft_i18n::fmt(if n == 1 { one } else { other }, &[("n", &n.to_string())]) + } +} + +/// A trigger as `platform` shows it, with key names from [`Text::key`]. Modifiers come in the +/// order of [`Chord::display`](crate::Chord::display). +pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> String { + let key = trigger.chord.key; + let Some(name) = text.key(key, platform) else { return trigger.display(platform) }; + let base = if platform.is_mac() { key.mac_symbol() } else { key.text_name() }; + let one = trigger.chord.display(platform); + let one = match one.strip_suffix(base) { + Some(mods) => format!("{mods}{name}"), + None => one, + }; + if trigger.double { format!("{one} {one}") } else { one } +} + +/// `s` in display order with `chord`, text inside it, left as it is: chord text never goes +/// through [`Text::visual`]. Right to left, the parts before and after the chord change sides. +pub(crate) fn visual_around(text: &dyn Text, s: &str, chord: &str) -> String { + let Some(i) = s.find(chord).filter(|_| !chord.is_empty()) else { return text.visual(s) }; + let (before, rest) = s.split_at(i); + let (before, after) = (text.visual(before), text.visual(&rest[chord.len()..])); + if text.rtl() { format!("{after}{chord}{before}") } else { format!("{before}{chord}{after}") } +} + +/// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. +pub const PLURALS: &[(&str, &str)] = &[ + ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), + ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), + ("{n} command will change", "{n} commands will change"), + ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} item not matched", "{n} items not matched"), + ("{n} item could not be read", "{n} items could not be read"), + ("{n} key read approximately", "{n} keys read approximately"), + ("{n} entry cut at a limit", "{n} entries cut at a limit"), + ("{n} command changed", "{n} commands changed"), + ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), + ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), +]; + +/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. +pub const STRINGS: &[&str] = &[ + // Window and footer. + "Keyboard Shortcuts", + "Custom", + "OK", + "Cancel", + "Close", + "Undo", + "Redo", + "Reset All…", + "Restore every default shortcut in this set?", + "Reset All", + // Header. + "Show:", + "Filter:", + "Search:", + "Search by key", + "All Commands", + "{menu} Menu", + "Tools", + "{panel} Panel", + "Other", + "Fixed Keys", + "All", + "With Shortcut", + "Changed", + "Conflicts", + "No matching commands", + "(cycles)", + "{label} (cycles)", + "{label} (Changed)", + // Table and detail pane. + "Command", + "Commands", + "Detail pane size", + "Select a command to see and change its shortcuts", + "Shortcut", + "Default", + "{chord} (Default)", + "{chord} ({scope})", + "{label} ({scope})", + "Application", + "Current shortcuts:", + "New shortcut:", + "New shortcut: {chord}", + "press keys", + "{chord} (press again for a double press)", + "Type…", + "Context:", + "Assign", + "Use Default", + "Clear", + "Default: {chord}", + "None", + "Currently assigned to:", + "Not assigned", + "{label} ({scope}, both work)", + "Add a shortcut to {label}", + "Remove {chord} from {label}", + "Restore the default for {label}", + "{label}: {chord}", + "{label}: press keys", + "Now used by {label}", + "{label}: {chord} (Now used by {holder})", + // Typed shortcuts and saved texts that do not parse. + "the shortcut has no key", + "unknown modifier {key}", + "modifier {key} given twice", + "key sequences are not supported", + "the scope name is not valid", + // On-screen keyboard. + "Keyboard", + "Keyboard: US", + "Keyboard: US (macOS)", + "Control", + "Option", + "Shift", + "Ctrl", + "Alt", + "Caps Lock", + "Win", + "Super", + "Panel or context", + "Both", + "Modifier on", + "{chord}: {label}", + "Click a key to see its shortcuts", + "Key: {key}", + "Assign to Selected", + "Assign {chord} to {label}", + "Select a command first", + "Nothing uses this key", + // Messages and conflicts. + "⚠ {chord} is used by {label}", + "{chord} was removed from {label}", + "Also used by {label}", + "{chord} is reserved by the system", + "A command can have at most {n} shortcuts", + "The scope {scope} is unknown", + "Too many shortcuts are assigned", + "This command is unknown", + "Take It", + "Take It and Go to Conflict", + "Keep Both", + "Go to Conflict", + "Cancel the new shortcut", + "Dismiss", + // Saved shortcuts that could not be used. + "{key}: no command has this name", + "{key}: “{text}” is not a shortcut ({error})", + "{key}: {text} is reserved by the system", + "{key}: the scope in “{text}” is unknown", + "{key}: listed more than once", + "{key}: part of the entry was dropped", + // Sets. + "Set:", + "(modified)", + "New Set…", + "Rename…", + "Delete", + "Save", + "Revert Set", + "Import…", + "Export", + "Export…", + "Summarize…", + "{title}: {set}", + "Create", + "Rename", + "More", + "Name:", + "Based on:", + "Current shortcuts", + "Save changes to “{set}”?", + "Don't Save", + "Delete the set “{set}”?", + "Keyboard Shortcuts (JSON)", + "The name is empty, too long or has control characters", + "A set with this name exists", + "The set was not found", + "Built-in sets cannot be changed", + "There are too many sets", + "This is not a shortcut set file", + "This is not a craft-keymap file", + "This file is from a newer version (format {n})", + "The file is too large", + // Import review. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", + "Will change", + "Not matched", + "Unreadable", + "Approximated", + "{title} ({n})", + "Suggestions ({n})", + "numeric keypad key read as main key", + "Matched without change", + "Command for {item}", + "Choose a command…", + "no command has this name", + "more than one command fits", + "in a context that is not imported", + "only a command of the other kind fits (tool or menu command)", + "empty shortcut", + "the shortcut is too long", + "unknown key {key}", + "unsupported modifier {key}", + "{label} keeps its shortcuts (matched by a panel menu name)", + "Import as New Set", + "Imported", + "Imported as “{set}”", + "Skipped", + "Taken from other commands", + "Not changed", + "taken by {label}", + "taken by a later entry", + "reserved by the system", + "the scope is unknown", + "the set's shortcuts could not be added", + "the shortcuts could not be removed", + "Copy Report", + "Done", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Chord; + + struct German; + impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, _one: &str, other: &str) -> String { + other.replace("{n}", &n.to_string()) + } + fn key(&self, k: Key, _p: Platform) -> Option { + (k == Key::Delete).then(|| "Entf".to_string()) + } + } + + #[test] + fn trigger_text_uses_localized_key_names() -> Result<(), crate::ParseError> { + let t = Trigger::parse("Cmd+Shift+Delete")?; + assert_eq!(trigger_text(&German, &t, Platform::Windows), "Ctrl+Shift+Entf"); + assert_eq!(trigger_text(&German, &Trigger::parse("F7 F7")?, Platform::Windows), "F7 F7"); + assert_eq!(trigger_text(&English, &Trigger::single(Chord::parse("⇧⌘Z")?), Platform::Mac), "⇧⌘Z"); + Ok(()) + } + + /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. + #[test] + fn every_looked_up_string_is_listed() { + let sources = [ + include_str!("mod.rs"), + include_str!("state.rs"), + include_str!("rows.rs"), + include_str!("capture.rs"), + include_str!("view.rs"), + include_str!("keyboard.rs"), + include_str!("style.rs"), + include_str!("sets.rs"), + include_str!("review.rs"), + include_str!("summary.rs"), + ]; + let mut missing = Vec::new(); + for src in sources { + let src = src.split("#[cfg(test)]").next().unwrap_or(src); + for (i, open) in src.match_indices(".ui(\"") { + let rest = &src[i + open.len()..]; + if let Some(end) = rest.find('"') { + let s = &rest[..end]; + if !STRINGS.contains(&s) { + missing.push(s.to_string()); + } + } + } + for (i, _) in src.match_indices(".plural(") { + let quoted: Vec<&str> = src[i..].split('"').skip(1).step_by(2).take(2).collect(); + if let [one, other] = quoted[..] + && !PLURALS.contains(&(one, other)) + { + missing.push(format!("{one}|{other}")); + } + } + } + assert!(missing.is_empty(), "not in STRINGS or PLURALS: {missing:?}"); + let mut sorted = STRINGS.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), STRINGS.len(), "STRINGS has duplicates"); + } +} diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs new file mode 100644 index 00000000..7aa4cb87 --- /dev/null +++ b/crates/keymap/src/ui/view.rs @@ -0,0 +1,1499 @@ +//! Drawing the dialog: header, table, detail pane, conflict bar, messages and footer. +//! +//! Widgets are created in focus order (§2.10 of the dialog plan): set row, Show, Filter, Search, +//! ⌨, table, detail pane, conflict bar, footer. egui's Tab order is creation order, so the bars +//! under the table are drawn last even though their height is needed first: the view uses last +//! frame's height and asks egui to discard a frame whose height changed. + +use std::ops::Range; +use std::sync::Arc; + +use craft_ui::buttons::{IconButton, IconButtonStyle}; +use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; +use craft_ui::tabs::{self, Tab}; +use egui::Galley; +use egui::accesskit::Live; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, Role, ScrollArea, + Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, pos2, vec2, +}; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::keymap::{Binding, MAX_BINDINGS, Problem}; +use crate::scope::Scope; +use crate::ui::capture; +use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target, binding_error}; +use crate::ui::style::{self, DialogStyle}; +use crate::ui::text::{trigger_text, visual_around}; + +#[path = "keyboard.rs"] +pub(crate) mod keyboard; +#[path = "review.rs"] +mod review; +#[path = "sets.rs"] +mod sets; + +/// Below this width the detail pane goes under the table. +const NARROW: f32 = 720.0; +/// Smallest table or detail pane when dragging the divider. +const SPLIT_MIN: f32 = 120.0; +/// Icon buttons in rows and bars. +const ICON: f32 = 20.0; +/// Padding inside a chip. +const CHIP_PAD: f32 = 5.0; +const CHIP_GAP: f32 = 4.0; +/// The detail pane's New Shortcut field. +const FIELD_W: f32 = 160.0; +/// The widest Show, Filter or Set combo box; longer names are cut with "…". +const COMBO_MAX: f32 = 200.0; +/// Search field width. +const SEARCH_MIN: f32 = 120.0; +const SEARCH_MAX: f32 = 240.0; + +/// One frame of the dialog body inside `ui`'s available rectangle. +pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { + let style = env.options.style.unwrap_or_else(|| DialogStyle::from_visuals(ui.visuals())); + let icons = style::icon_buttons(ui.visuals()); + let row_h = ui.text_style_height(&TextStyle::Body) + 6.0; + let base = ui.scope_id().with("craft_keymap_shortcuts"); + let mut d = Draw { env, state, style, icons, base, row_h }; + d.keys(ui); + d.layout(ui); +} + +struct Draw<'s, 'e> { + env: &'s mut Env<'e>, + state: &'s mut DialogState, + style: DialogStyle, + icons: IconButtonStyle, + base: Id, + row_h: f32, +} + +fn fill(source: String, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&source, args) +} + +/// What a click in the table asked for, applied after the rows are drawn. +enum RowAction { + Select(CommandRef), + Record(CommandRef, Option, usize), + Reset(CommandRef), + Toggle(String), +} + +impl Draw<'_, '_> { + // ---- Text ---- + + /// The dialog's string, translated (logical order: for accessibility labels and `fill`). + fn ui(&self, source: &str) -> String { + self.env.text.ui(source).into_owned() + } + + /// Display order for drawn text. + fn vis(&self, s: &str) -> String { + self.env.text.visual(s) + } + + fn rtl(&self) -> bool { + self.env.text.rtl() + } + + /// Parts in logical order, each already in display order, put into display order: reversed in + /// a right-to-left interface, where the path separator ` › ` becomes ` ‹ `. + fn ordered(&self, mut parts: Vec<(String, T)>) -> Vec<(String, T)> { + if self.rtl() { + parts.reverse(); + for (s, _) in &mut parts { + if s == PATH_SEP { + *s = " ‹ ".into(); + } + } + } + parts + } + + /// [`ordered`](Self::ordered) parts as one string. + fn joined(&self, parts: Vec) -> String { + self.ordered(parts.into_iter().map(|s| (s, ())).collect()).into_iter().map(|(s, ())| s).collect() + } + + /// Display-order `parts` on one line within `width`, cut with "…" when they do not fit: at + /// the end, or at the start in a right-to-left interface, where the logical end of the text is + /// on the left. Returns the galley and whether it was cut. + fn one_line(&self, painter: &Painter, parts: &[(String, TextFormat)], width: f32) -> (Arc, bool) { + let width = width.max(0.0); + let job = |skip: usize| { + let mut job = LayoutJob::default(); + if let (true, Some((_, f))) = (skip > 0, parts.first()) { + job.append("…", 0.0, f.clone()); + } + let mut left = skip; + for (s, f) in parts { + let n = s.chars().count(); + if left >= n { + left -= n; + continue; + } + let start = s.char_indices().nth(left).map_or(s.len(), |(i, _)| i); + left = 0; + job.append(&s[start..], 0.0, f.clone()); + } + job + }; + let full = painter.layout_job(job(0)); + if full.size().x <= width + 0.5 { + return (full, false); + } + if !self.rtl() { + let mut cut = job(0); + cut.wrap = TextWrapping::truncate_at_width(width); + return (painter.layout_job(cut), true); + } + // The fewest characters to drop from the start so that "…" and the rest fit. + let total: usize = parts.iter().map(|(s, _)| s.chars().count()).sum(); + let (mut lo, mut hi) = (1, total); + while lo < hi { + let mid = (lo + hi) / 2; + if painter.layout_job(job(mid)).size().x <= width + 0.5 { hi = mid } else { lo = mid + 1 } + } + (painter.layout_job(job(lo)), true) + } + + /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes + /// through [`Text::visual`](crate::ui::Text::visual). + fn vis_chord(&self, s: &str, chord: &str) -> String { + visual_around(self.env.text, s, chord) + } + + /// `text` after a ⚠, in display order. + fn warned(&self, text: String) -> String { + self.joined(vec!["⚠".into(), " ".into(), text]) + } + + /// Horizontal placement inside `rect`, mirrored in a right-to-left interface. + fn mirror(&self, rect: Rect) -> Mirror { + Mirror { on: self.rtl(), sum: rect.min.x + rect.max.x } + } + + fn chord(&self, t: &Trigger) -> String { + trigger_text(self.env.text, t, self.env.keymap.platform()) + } + + fn scope_name(&self, s: &Scope) -> String { + if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } + } + + /// A binding as text, with its scope when that is not the command's home scope. + fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => fill(self.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &self.scope_name(s))]), + _ => chord, + } + } + + /// A menu path in display order: "File › Export", "Export ‹ File" right to left. + fn path_text(&self, path: &[String]) -> String { + let mut parts = Vec::new(); + for (i, s) in path.iter().enumerate() { + if i > 0 { + parts.push(PATH_SEP.to_string()); + } + parts.push(self.vis(&self.env.text.segment(s))); + } + self.joined(parts) + } + + fn label_of(&self, command: &CommandRef) -> String { + self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) + } + + /// "press keys", or the chord waiting for a second press. + fn recording_text(&self) -> String { + match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { + Some((t, _)) => { + let chord = self.chord(t); + self.vis_chord(&fill(self.ui("{chord} (press again for a double press)"), &[("chord", &chord)]), &chord) + } + None => self.ui("press keys"), + } + } + + /// Reports a control to [`Options::on_widget`](crate::ui::Options::on_widget). + fn tag(&self, id: impl FnOnce() -> String, rect: Rect, label: &str) { + if let Some(f) = &self.env.options.on_widget { + f(&id(), rect, label); + } + } + + fn button(&self, ui: &mut Ui, enabled: bool, text: &str, id: &str) -> bool { + let r = ui.add_enabled(enabled, Button::new(self.vis(text))); + self.tag(|| id.to_string(), r.rect, text); + r.clicked() + } + + /// The width a [`Button`] with `text` takes. + fn button_width(&self, ui: &Ui, text: &str) -> f32 { + let pad = ui.spacing().button_padding.x * 2.0; + let g = WidgetText::from(self.vis(text)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(ui.spacing().interact_size.x) + } + + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) + } + + /// A row control: clickable and labeled but not a Tab stop (the table is one). + fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, label)); + let v = if !enabled { + self.icons.normal + } else if resp.is_pointer_button_down_on() { + self.icons.pressed.unwrap_or(self.icons.hovered) + } else if resp.hovered() { + self.icons.hovered + } else { + self.icons.normal + }; + if v.fill != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, self.icons.corner_radius, v.fill); + } + let color = if enabled { v.icon } else { self.style.dim.gamma_multiply(0.6) }; + paint(ui.painter(), rect, color); + resp + } + + /// Marks a text field of the dialog, so Escape, Enter, Undo and Delete leave it alone. + fn text_field(&mut self, r: &Response) { + if r.has_focus() { + self.state.text_focus = true; + } + } + + fn recording_target(&self) -> Option<&Target> { + self.state.recording.as_ref().map(|r: &Recording| &r.target) + } + + // ---- Keys when nothing records ---- + + /// Escape, Undo/Redo and Enter (§2.5). Keys while recording were taken by `DialogState::input`. + fn keys(&mut self, ui: &Ui) { + if self.state.is_recording() { + return; + } + let text_focus = self.state.text_focus; + let popup = egui::Popup::is_any_open(ui.ctx()); + if !text_focus && !popup && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Escape)) { + if self.state.typing.take().is_some() { + self.state.type_error = None; + } else { + self.state.escape(self.env); + } + return; + } + if text_focus || !self.env.options.editable || self.state.modal() { + return; + } + let mac = self.env.keymap.platform().is_mac(); + let (undo, redo) = ui.input_mut(|i| { + let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::Z) || (!mac && i.consume_key(Modifiers::COMMAND, EguiKey::Y)); + let undo = !redo && i.consume_key(Modifiers::COMMAND, EguiKey::Z); + (undo, redo) + }); + if redo { + self.state.redo(self.env); + } else if undo { + self.state.undo(self.env); + } + if self.env.options.enter_is_ok + && self.state.prompt.is_none() + && ui.memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Enter)) + { + self.state.enter(self.env); + } + } + + // ---- Layout ---- + + fn layout(&mut self, ui: &mut Ui) { + let avail = ui.available_rect_before_wrap(); + let size = |v: f32, or: f32| if v.is_finite() { v.clamp(0.0, 4096.0) } else { or }; + let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); + self.state.text_focus = false; + let gap = ui.spacing().item_spacing.y; + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(full).layout(column), |ui| { + self.sets_row(ui); + // Show, Filter and Search act on the table, which the import review replaces. + ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); + let rest = ui.available_rect_before_wrap(); + let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); + let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); + let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); + if !self.review(ui, mid) { + let prompt = self.state.prompt.is_some(); + ui.scope(|ui| { + if prompt { + ui.disable(); + } + self.middle(ui, mid); + }); + } + let top = (mid.max.y + gap).min(rest.max.y); + let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); + let used = ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(column), |ui| self.bottom(ui)).response.rect.height(); + if (used - self.state.bottom_h).abs() > 0.5 { + self.state.bottom_h = used; + ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); + } + }); + ui.advance_cursor_after_rect(full); + } + + /// Top to bottom, aligned to the reading side. In a right-to-left interface egui's + /// `horizontal` rows inside such a column also run right to left. + fn column(&self) -> Layout { + Layout::top_down(if self.env.text.rtl() { Align::Max } else { Align::Min }) + } + + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(column), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + f(self, ui); + }); + } + + // ---- Header: Show, Filter, Search ---- + + fn area_key(id: &AreaId) -> String { + match id { + AreaId::All => "all".into(), + AreaId::Menu(m) => format!("menu.{m}"), + AreaId::Tools => "tools".into(), + AreaId::Panel(p) => format!("panel.{p}"), + AreaId::Other => "other".into(), + AreaId::App(a) => (*a).to_string(), + AreaId::Fixed => "fixed".into(), + } + } + + fn header(&mut self, ui: &mut Ui) { + if self.env.options.area_tabs { + self.area_tabs(ui); + } + // Show and Filter, then Search, ⌨ and Keyboard; Search goes to a row of its own when the + // two do not fit side by side. + let gap = ui.spacing().item_spacing.x; + let text_w = |d: &Self, ui: &Ui, s: &str| d.button_width(ui, s) - ui.spacing().button_padding.x * 2.0; + let mut first = text_w(self, ui, &self.ui("Filter:")) + COMBO_MAX + 2.0 * gap; + if !self.env.options.area_tabs { + first += text_w(self, ui, &self.ui("Show:")) + COMBO_MAX + 2.0 * gap; + } + let toggle = if self.env.options.keyboard { self.button_width(ui, &self.ui("Keyboard")) + gap } else { 0.0 }; + let second = text_w(self, ui, &self.ui("Search:")) + SEARCH_MIN + ICON + toggle + 3.0 * gap; + let one_row = first + second <= ui.available_width(); + ui.horizontal(|ui| { + if !self.env.options.area_tabs { + self.area_combo(ui); + } + self.filter_combo(ui); + if one_row { + self.search(ui, toggle); + } + }); + if !one_row { + ui.horizontal(|ui| self.search(ui, toggle)); + } + } + + /// A combo box at most [`COMBO_MAX`] wide, its text cut with "…" and shown in full on hover. + fn combo(&self, ui: &mut Ui, salt: &str, label: &Response, title: &str, current: &str, items: impl FnOnce(&mut Ui)) -> Response { + let full = self.vis(current); + let natural = self.button_width(ui, &full) + ui.spacing().icon_width + ui.spacing().icon_spacing; + let width = natural.clamp(ui.spacing().combo_width, COMBO_MAX); + let r = ui + .allocate_ui(vec2(width, ui.spacing().interact_size.y), |ui| { + ComboBox::from_id_salt(self.base.with(salt)).width(width).truncate().selected_text(full.clone()).show_ui(ui, items).response + }) + .inner + .labelled_by(label.id); + self.tag(|| format!("shortcuts.{salt}"), r.rect, title); + if natural > width { r.on_hover_text(full) } else { r } + } + + fn filter_combo(&mut self, ui: &mut Ui) { + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + self.combo(ui, "filter", &l, &filter_label, ¤t, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + if let Some(f) = pick { + self.state.set_filter(f); + } + } + + /// Search, search by key and the keyboard pane's toggle (`toggle` wide, 0 without it). + fn search(&mut self, ui: &mut Ui, toggle: f32) { + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let gap = ui.spacing().item_spacing.x; + let width = (ui.available_width() - ICON - toggle - 2.0 * gap).clamp(SEARCH_MIN.min(ui.available_width()), SEARCH_MAX); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } else if r.has_focus() { + capture::unlock_focus(ui, r.id); + } + self.keyboard_toggle(ui); + } + + fn area_combo(&mut self, ui: &mut Ui) { + let show = self.ui("Show:"); + let l = ui.label(self.vis(&show)); + let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); + let mut pick = None; + let areas = self.state.areas.clone(); + self.combo(ui, "area", &l, &show, ¤t, |ui| { + for a in &areas { + let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); + if r.clicked() { + pick = Some(a.id.clone()); + } + } + }); + if let Some(a) = pick { + self.state.set_area(a); + } + } + + /// Show as tabs, with the ones that do not fit in a menu. + fn area_tabs(&mut self, ui: &mut Ui) { + let areas = self.state.areas.clone(); + let pad = ui.spacing().button_padding.x * 2.0; + let font = TextStyle::Button.resolve(ui.style()); + let natural: Vec = + areas.iter().map(|a| ui.painter().layout_no_wrap(self.vis(&a.label), font.clone(), Color32::WHITE).size().x + pad).collect(); + let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); + let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let m = self.mirror(rect); + let mut x = rect.min.x; + let mut pick = None; + for &(i, w) in &fit.shown { + let Some(a) = areas.get(i) else { continue }; + let r = m.rect(Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height()))); + x += w; + let on = i == selected; + let id = self.base.with(("area", i)); + let text = self.vis(&a.label); + let accent = self.style.accent; + let resp = Tab::new(id, &a.label, on).show_at(ui, r, |ui, resp| { + let v = ui.visuals(); + let color = if on { v.strong_text_color() } else { v.text_color() }; + if resp.hovered() && !on { + ui.painter().rect_filled(r, 2.0, v.widgets.hovered.weak_bg_fill); + } + if on { + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); + } + let (g, _) = self.one_line(ui.painter(), &[(text, TextFormat::simple(font.clone(), color))], r.width() - 4.0); + ui.painter().galley(r.center() - g.size() / 2.0, g, color); + }); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); + if resp.clicked() { + pick = Some(a.id.clone()); + } + } + if !fit.overflow.is_empty() { + let r = m.rect(Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height()))); + let more = self.ui("More"); + ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { + ui.menu_button(self.vis(&more), |ui| { + for &i in &fit.overflow { + if let Some(a) = areas.get(i) + && ui.button(self.vis(&a.label)).clicked() + { + pick = Some(a.id.clone()); + } + } + }); + }); + } + if let Some(a) = pick { + self.state.set_area(a); + } + } + + // ---- Table and detail pane ---- + + /// The table and detail pane, with the keyboard pane under both when it is on. + fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let Some(kb_h) = self.keyboard_height(mid.height()) else { + self.table_and_detail(ui, mid); + return; + }; + let gap = ui.spacing().item_spacing.y; + let top = Rect::from_min_max(mid.min, pos2(mid.max.x, mid.max.y - kb_h - gap)); + let pane = Rect::from_min_max(pos2(mid.min.x, mid.max.y - kb_h), mid.max); + self.table_and_detail(ui, top); + self.child(ui, pane, |d, ui| d.keyboard_pane(ui)); + } + + fn table_and_detail(&mut self, ui: &mut Ui, mid: Rect) { + let wide = mid.width() >= NARROW; + let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; + // Right to left: the detail pane goes on the left. + let swap = wide && self.env.text.rtl(); + let ratio = if swap { 1.0 - self.state.split } else { self.state.split }; + let extent = if wide { mid.width() } else { mid.height() }; + let Some(split) = split_rect(mid, axis, SplitSize::Ratio(ratio), 8.0, SPLIT_MIN.min(extent / 3.0)) else { return }; + let (table, detail) = if swap { (split.second, split.first) } else { (split.first, split.second) }; + self.child(ui, table, |d, ui| d.table(ui)); + let label = self.ui("Detail pane size"); + let resp = ResizeHandle::new(self.base.with("split"), axis, split.divider, &label, style::divider(ui.visuals())) + .value(split.first_size(), 0.0..=split.available()) + .show(ui); + let line = ui.visuals().widgets.noninteractive.bg_stroke; + let c = split.divider.center(); + let ends = if wide { + [pos2(c.x, split.divider.min.y), pos2(c.x, split.divider.max.y)] + } else { + [pos2(split.divider.min.x, c.y), pos2(split.divider.max.x, c.y)] + }; + ui.painter().line_segment(ends, line); + if resp.delta != 0.0 + && let Some(SplitSize::Ratio(r)) = split.resized(SplitSize::Ratio(ratio), resp.delta, SPLIT_MIN) + { + self.state.split = if swap { 1.0 - r } else { r }; + } + self.child(ui, detail, |d, ui| d.detail(ui)); + } + + /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. + fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { + let font = TextStyle::Body.resolve(ui.style()); + let chip_w = |text: &str| ui.painter().layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP; + let chips = self + .state + .lines_cached() + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r.chips.iter().map(|c| chip_w(&c.text)).sum::()) } else { None }) + .fold(0.0, f32::max); + (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) + } + + fn table(&mut self, ui: &mut Ui) { + let rect = ui.max_rect(); + let table_id = self.base.with("table"); + let focus = ui.interact(rect, table_id, Sense::focusable_noninteractive()); + let commands = self.ui("Commands"); + focus.widget_info(|| WidgetInfo::labeled(Role::Unknown, true, &commands)); + let has_focus = focus.has_focus(); + if has_focus && !self.state.is_recording() { + self.table_keys(ui); + } + let recording_cell = matches!(self.recording_target(), Some(Target::Cell { .. })); + if recording_cell && focus.lost_focus() { + self.state.stop_recording(); + } + if has_focus { + let filter = if recording_cell { + capture::FOCUS_LOCK + } else { + EventFilter { vertical_arrows: true, horizontal_arrows: true, tab: false, escape: false } + }; + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, filter)); + } + + let selected = self.state.selected.clone(); + let built = self.state.lines(self.env); + let (count, matches) = (built.lines.len(), built.matches); + let position = selected.as_ref().and_then(|c| built.position(c)); + let row_h = self.row_h; + let shortcut_w = self.shortcut_width(ui, rect.width()); + + // Column titles. + let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); + let font = TextStyle::Body.resolve(ui.style()); + let dim = self.style.dim; + let m = self.mirror(head); + let key_x = head.max.x - shortcut_w - ICON - 4.0; + let titles = [(self.ui("Command"), head.min.x + 6.0, key_x - head.min.x - 12.0), (self.ui("Shortcut"), key_x, shortcut_w)]; + for (title, x, w) in titles { + let (g, _) = self.one_line(ui.painter(), &[(self.vis(&title), TextFormat::simple(font.clone(), dim))], w); + ui.painter().galley(pos2(m.x(x, g.size().x), head.center().y - g.size().y / 2.0), g, dim); + } + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); + + if matches == 0 { + ui.add_space(8.0); + ui.label(RichText::new(self.vis(&self.ui("No matching commands"))).color(dim)); + } + + let mut scroll = ScrollArea::vertical().id_salt(self.base.with("rows")).auto_shrink([false, false]); + if std::mem::take(&mut self.state.reveal) + && let Some(pos) = position + { + let inside = self.state.visible.start < pos && pos + 1 < self.state.visible.end; + if !inside { + let view = ui.available_height(); + scroll = scroll.vertical_scroll_offset((pos as f32 * row_h - (view - row_h) / 2.0).max(0.0)); + } + } + let mut action = None; + let mut shown = 0..0; + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + scroll.show_rows(ui, row_h, count, |ui, range| { + shown = range.clone(); + action = self.rows(ui, range, shortcut_w, has_focus); + }); + }); + // Screen readers follow the selected row while the table has focus. + let row_id = position.filter(|p| shown.contains(p)).and_then(|p| match self.state.lines_cached().get(p) { + Some(Line::Row(r)) => Some(self.base.with(("row", &r.key))), + _ => None, + }); + if let Some(id) = row_id { + ui.ctx().accesskit_node_builder(table_id, |node| node.set_active_descendant(id.accesskit_id())); + } + self.state.visible = shown; + if has_focus { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + + if let Some(a) = action { + // A group header is a Tab stop of its own and keeps focus. + if !matches!(a, RowAction::Toggle(_)) { + focus.request_focus(); + } + match a { + RowAction::Select(c) => self.state.select_row(&c), + RowAction::Record(c, old, chip) => { + self.state.record(self.env, Target::Cell { command: c, old }); + self.state.chip = chip; + } + RowAction::Reset(c) => { + self.state.select_row(&c); + self.state.use_default(self.env, &c); + } + RowAction::Toggle(name) => self.state.toggle_group(&name), + } + if self.state.is_recording() { + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, capture::FOCUS_LOCK)); + } + } + } + + /// Up/Down, Home/End, Left/Right between chips, Enter records, Delete removes (§2.10). + fn table_keys(&mut self, ui: &Ui) { + let keys = [ + EguiKey::ArrowUp, + EguiKey::ArrowDown, + EguiKey::Home, + EguiKey::End, + EguiKey::ArrowLeft, + EguiKey::ArrowRight, + EguiKey::Enter, + EguiKey::Delete, + ]; + let pressed = ui.input_mut(|i| keys.map(|k| i.consume_key(Modifiers::NONE, k))); + let [up, down, home, end, left, right, enter, delete] = pressed; + if !pressed.contains(&true) { + return; + } + let rows: Vec<(CommandRef, Vec)> = self + .state + .lines(self.env) + .lines + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some((r.command.clone(), r.chips.iter().map(|c| c.binding.clone()).collect())) } else { None }) + .collect(); + let at = self.state.selected.as_ref().and_then(|s| rows.iter().position(|(c, _)| c == s)); + let to = if up { + Some(at.map_or(0, |i| i.saturating_sub(1))) + } else if down { + Some(at.map_or(0, |i| (i + 1).min(rows.len().saturating_sub(1)))) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some((command, _)) = to.and_then(|i| rows.get(i)) { + self.state.select_row(command); + self.state.reveal = true; + return; + } + let Some((command, bindings)) = at.and_then(|i| rows.get(i)) else { return }; + let chip = self.state.chip.min(bindings.len().saturating_sub(1)); + if left { + self.state.chip = chip.saturating_sub(1); + } else if right { + self.state.chip = (chip + 1).min(bindings.len().saturating_sub(1)); + } else if enter && self.env.options.editable { + self.state.record(self.env, Target::Cell { command: command.clone(), old: bindings.get(chip).cloned() }); + self.state.chip = chip; + } else if delete + && self.env.options.editable + && let Some(b) = bindings.get(chip) + { + self.state.remove(self.env, command, b); + self.state.chip = chip.saturating_sub(1); + } + } + + fn rows(&mut self, ui: &mut Ui, range: Range, shortcut_w: f32, focused: bool) -> Option { + let lines: Vec = self.state.lines(self.env).lines.get(range).map(<[Line]>::to_vec).unwrap_or_default(); + let mut action = None; + for line in lines { + let (_, rect) = ui.allocate_space(vec2(ui.available_width(), self.row_h)); + let a = match &line { + Line::Header { name, collapsed } => self.group_header(ui, rect, name, *collapsed), + Line::Row(r) => self.row(ui, rect, r, shortcut_w, focused), + Line::Fixed(f) => { + self.fixed_row(ui, rect, f, shortcut_w); + None + } + }; + action = action.or(a); + } + action + } + + /// A group's header; a Tab stop, toggled with Enter or Space. + fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::click()); + resp.widget_info(|| WidgetInfo::labeled(Role::DisclosureTriangle, true, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_expanded(!collapsed)); + if resp.has_focus() { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + let v = ui.visuals(); + let color = v.strong_text_color(); + let m = self.mirror(rect); + let tri = m.rect(Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0))); + let points = match (collapsed, m.on) { + (true, false) => vec![tri.left_top(), tri.right_center(), tri.left_bottom()], + (true, true) => vec![tri.right_top(), tri.right_bottom(), tri.left_center()], + (false, _) => vec![tri.left_top(), tri.right_top(), tri.center_bottom()], + }; + ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); + let font = TextStyle::Body.resolve(ui.style()); + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(name), TextFormat::simple(font, color))], rect.width() - 24.0); + ui.painter().galley(pos2(m.x(rect.min.x + 20.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let clicked = resp.clicked(); + if cut { + resp.on_hover_text(self.vis(name)); + } + clicked.then(|| RowAction::Toggle(name.to_string())) + } + + fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { + let editable = self.env.options.editable; + let selected = self.state.selected.as_ref() == Some(&r.command); + let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); + let mut a11y = r.label.clone(); + if r.cycles { + a11y = fill(self.ui("{label} (cycles)"), &[("label", &a11y)]); + } + if r.changed { + a11y = fill(self.ui("{label} (Changed)"), &[("label", &a11y)]); + } + resp.widget_info(|| WidgetInfo::selected(Role::Button, true, selected, &a11y)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); + self.tag(|| format!("shortcuts.row.{}", r.key), rect, &r.label); + let v = ui.visuals(); + if selected { + let fill = if focused { v.selection.bg_fill } else { v.selection.bg_fill.gamma_multiply(0.5) }; + ui.painter().rect_filled(rect, 0.0, fill); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); + } + let m = self.mirror(rect); + if r.changed { + ui.painter().rect_filled(m.rect(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0))), 1.0, self.style.changed); + } + let text_color = if selected { v.strong_text_color() } else { v.text_color() }; + let strong = v.strong_text_color(); + let dim = self.style.dim; + let font = TextStyle::Body.resolve(ui.style()); + + // Label column. + let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); + let fmt = |c: Color32| TextFormat::simple(font.clone(), c); + let mut parts = Vec::new(); + if r.conflict { + parts.extend([("⚠".to_string(), fmt(self.style.warning)), (" ".into(), fmt(dim))]); + } + for p in &r.prefix { + parts.extend([(self.vis(p), fmt(dim)), (PATH_SEP.into(), fmt(dim))]); + } + parts.push((self.vis(&r.label), fmt(if r.changed { strong } else { text_color }))); + if r.cycles { + parts.extend([(" ".into(), fmt(dim)), (self.vis(&self.ui("(cycles)")), fmt(dim))]); + } + let (g, _) = self.one_line(ui.painter(), &self.ordered(parts), label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, text_color); + + // Shortcut column: chips, then "+". + let mut action = None; + let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); + let painter = ui.painter_at(m.rect(col)); + let mut x = col.min.x; + let rec = match self.recording_target() { + Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), + _ => None, + }; + let chip_h = rect.height() - 4.0; + let mut cells: Vec<(Option, String, bool)> = + r.chips.iter().map(|c| (Some(c.binding.clone()), c.text.clone(), rec.as_ref() == Some(&Some(c.binding.clone())))).collect(); + if rec == Some(None) { + cells.push((None, String::new(), true)); + } + for (n, (binding, text, recording)) in cells.iter().enumerate() { + let shown = if *recording { self.recording_text() } else { text.clone() }; + let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); + let w = g.size().x + 2.0 * CHIP_PAD; + let chip = m.rect(Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h))); + x += w + CHIP_GAP; + let changed = r.chips.get(n).is_some_and(|c| c.changed); + let stroke = if *recording { + Stroke::new(1.5, self.style.accent) + } else if changed { + Stroke::new(1.0, self.style.changed) + } else { + v.widgets.inactive.bg_stroke + }; + let current_chip = selected && focused && n == self.state.chip; + painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); + painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); + let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); + let label = if *recording && waiting { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &shown)]) + } else if *recording { + fill(self.ui("{label}: press keys"), &[("label", &r.label)]) + } else { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) + }; + let resp = + ui.interact(chip.intersect(m.rect(col)), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), binding.clone(), n)); + } + } + if cells.is_empty() { + // An empty cell to click for the first shortcut. + let cell = m.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); + let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); + let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.0", r.key), cell, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, 0)); + } + x = col.min.x; + } + if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { + let plus = m.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); + self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); + } + } + if editable { + let reset = m.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); + let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); + self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); + if resp.clicked() { + action = Some(RowAction::Reset(r.command.clone())); + } + } + + let clicked = resp.clicked(); + let mut tip = vec![self.vis(&r.label)]; + if let Some(b) = self.env.keymap.bindable(&r.command) { + if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { + tip.push(self.path_text(path)); + } + if let Some(h) = self.env.text.hint(b) { + tip.push(self.vis(&h)); + } + } + resp.on_hover_text(tip.join("\n")); + action.or_else(|| clicked.then(|| RowAction::Select(r.command.clone()))) + } + + /// A read-only row from `Options::fixed`, struck through when a command took its chord. + fn fixed_row(&self, ui: &mut Ui, rect: Rect, f: &FixedLine, shortcut_w: f32) { + let v = ui.visuals(); + let font = TextStyle::Body.resolve(ui.style()); + let color = if f.now_used_by.is_some() { self.style.dim } else { v.text_color() }; + let mut fmt = TextFormat::simple(font.clone(), color); + if f.now_used_by.is_some() { + fmt.strikethrough = Stroke::new(1.0, color); + } + let m = self.mirror(rect); + let label_w = rect.width() - shortcut_w - ICON - 12.0; + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(&f.label), fmt.clone())], label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); + ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + let mut tip = Vec::new(); + if cut { + tip.push(self.vis(&f.label)); + } + match &f.now_used_by { + Some(by) => { + let label = fill(self.ui("{label}: {chord} (Now used by {holder})"), &[("label", &f.label), ("chord", &f.keys), ("holder", by)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); + tip.push(self.vis(&fill(self.ui("Now used by {label}"), &[("label", by)]))); + } + None => { + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, &label)); + } + } + if !tip.is_empty() { + resp.on_hover_text(tip.join("\n")); + } + } + + fn detail(&mut self, ui: &mut Ui) { + ScrollArea::vertical().id_salt(self.base.with("detail")).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(4.0); + let Some(command) = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()) else { + ui.label(RichText::new(self.vis(&self.ui("Select a command to see and change its shortcuts"))).color(self.style.dim)); + return; + }; + self.detail_of(ui, &command); + }); + } + + fn detail_of(&mut self, ui: &mut Ui, command: &CommandRef) { + let Some(b) = self.env.keymap.bindable(command).cloned() else { return }; + let editable = self.env.options.editable; + let label = self.env.text.command(&b).into_owned(); + let home = Some(&b.scope); + let dim = self.style.dim; + ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); + let path: Vec = b.paths.first().map(|p| p.iter().filter(|s| !s.is_empty()).cloned().collect()).unwrap_or_default(); + let place = + if path.is_empty() { b.command.id.clone() } else { self.joined(vec![self.path_text(&path), " · ".into(), b.command.id.clone()]) }; + ui.add(Label::new(RichText::new(place).color(dim)).wrap()); + if let Some(h) = self.env.text.hint(&b) { + ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); + } + ui.add_space(6.0); + + // Current shortcuts, each with Remove. + ui.label(self.vis(&self.ui("Current shortcuts:"))); + let bindings = self.env.keymap.bindings(command); + let defaults = self.env.keymap.default_bindings(command); + if bindings.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("None"))).color(dim)); + } + let mut remove = None; + for (n, binding) in bindings.iter().enumerate() { + let text = self.binding_text(binding, home); + ui.horizontal(|ui| { + ui.add_space(8.0); + let shown = if defaults.contains(binding) { fill(self.ui("{chord} (Default)"), &[("chord", &text)]) } else { text.clone() }; + ui.label(self.vis_chord(&shown, &self.chord(&binding.trigger))); + if editable { + let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); + let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); + self.tag(|| format!("shortcuts.remove.{n}"), r.rect, &a11y); + if r.clicked() { + remove = Some(binding.clone()); + } + } + }); + } + if let Some(binding) = remove { + self.state.remove(self.env, command, &binding); + } + if !editable { + self.defaults_line(ui, &defaults, home); + return; + } + ui.add_space(6.0); + self.new_shortcut(ui, command, home); + self.context(ui, command); + self.holders(ui); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if self.button(ui, self.state.field.is_some(), &self.ui("Assign"), "shortcuts.assign") { + self.state.assign_field(self.env); + } + if self.button(ui, DialogState::can_use_default(self.env, command), &self.ui("Use Default"), "shortcuts.useDefault") { + self.state.use_default(self.env, command); + } + if self.button(ui, !bindings.is_empty(), &self.ui("Clear"), "shortcuts.clear") { + self.state.clear(self.env, command); + } + }); + self.defaults_line(ui, &defaults, home); + } + + fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { + let chords = + if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; + let text = self.vis_chord(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]), &chords); + ui.add(Label::new(RichText::new(text).color(self.style.dim)).wrap()); + } + + /// New Shortcut: a field that records when focused or clicked, or typed entry. + fn new_shortcut(&mut self, ui: &mut Ui, command: &CommandRef, home: Option<&Scope>) { + let title = self.ui("New shortcut:"); + let title_id = ui.label(self.vis(&title)).id; + let field_id = self.base.with("new"); + let recording = matches!(self.recording_target(), Some(Target::Field { command: c }) if c == command); + if let Some(mut typed) = self.state.typing.clone() { + let type_id = self.base.with("type"); + let edit = ui.add(TextEdit::singleline(&mut typed).id(type_id).desired_width(FIELD_W).hint_text("Cmd+Shift+K")); + let edit = edit.labelled_by(title_id); + // Focus once, on the frame after Type… was clicked. + if ui.data_mut(|d| d.remove_temp::(type_id)).is_some() { + edit.request_focus(); + } + self.text_field(&edit); + self.tag(|| "shortcuts.type".into(), edit.rect, &title); + let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(EguiKey::Enter)); + self.state.typing = Some(typed.clone()); + if enter { + match self.state.type_binding(self.env, command, &typed) { + Ok(()) => { + self.state.typing = None; + self.state.type_error = None; + } + Err(e) => self.state.type_error = Some(e), + } + } + if let Some(e) = &self.state.type_error { + ui.add(Label::new(RichText::new(self.warned(self.vis(e))).color(self.style.warning)).wrap()); + } + return; + } + ui.horizontal(|ui| { + let shown = if recording { + self.recording_text() + } else { + self.state.field.as_ref().filter(|f| f.command == *command).map_or_else(String::new, |f| self.binding_text(&f.binding, home)) + }; + let size = vec2(FIELD_W, ui.spacing().interact_size.y); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, field_id, Sense::click()); + let value = if shown.is_empty() { self.ui("None") } else { shown.clone() }; + let a11y = fill(self.ui("New shortcut: {chord}"), &[("chord", &value)]); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &a11y)); + let v = ui.visuals(); + let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; + ui.painter().rect(rect, 3.0, v.extreme_bg_color, stroke, StrokeKind::Inside); + let color = if recording { self.style.dim } else { v.text_color() }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, shown, TextStyle::Body.resolve(ui.style()), color); + self.tag(|| "shortcuts.new".into(), rect, &title); + if !recording && (resp.clicked() || resp.gained_focus()) { + self.state.record(self.env, Target::Field { command: command.clone() }); + resp.request_focus(); + capture::lock_focus(ui, field_id); + } else if recording { + if resp.lost_focus() { + self.state.stop_recording(); + } else { + capture::lock_focus(ui, field_id); + } + } else if resp.has_focus() { + capture::unlock_focus(ui, field_id); + } + if ui.link(self.vis(&self.ui("Type…"))).clicked() { + self.state.stop_recording(); + self.state.typing = Some(String::new()); + self.state.type_error = None; + let type_id = self.base.with("type"); + ui.data_mut(|d| d.insert_temp(type_id, true)); + } + }); + } + + fn context(&mut self, ui: &mut Ui, command: &CommandRef) { + let contexts = DialogState::contexts(self.env, command); + let name = |s: &Option| match s { + None => self.ui("Default"), + Some(s) => self.scope_name(s), + }; + let current = name(&self.state.context); + let names: Vec = contexts.iter().map(name).collect(); + let title = self.ui("Context:"); + let mut pick = None; + ui.horizontal(|ui| { + let l = ui.label(self.vis(&title)); + let combo = ComboBox::from_id_salt(self.base.with("context")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for (s, n) in contexts.iter().zip(&names) { + if ui.selectable_label(*s == self.state.context, self.vis(n)).clicked() { + pick = Some(s.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.context".into(), combo.rect, &title); + }); + if let Some(s) = pick { + self.state.set_context(self.env, s); + } + } + + /// "Currently assigned to:" for the field's chord, with links to the holders. + fn holders(&mut self, ui: &mut Ui) { + let Some(f) = self.state.field.clone() else { return }; + if f.holders.is_empty() && f.shadows.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Not assigned"))).color(self.style.dim)); + return; + } + ui.label(self.vis(&self.ui("Currently assigned to:"))); + let mut go = None; + for h in &f.holders { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); + let text = match &h.binding.scope { + Some(s) if Some(s) != home.as_ref() => fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(s))]), + _ => label, + }; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.warned(self.vis(&text))).clicked() { + go = Some(h.command.clone()); + } + }); + } + for h in &f.shadows { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()).unwrap_or_else(Scope::app); + let scope = self.scope_name(h.binding.scope_or(&home)); + let text = fill(self.ui("{label} ({scope}, both work)"), &[("label", &label), ("scope", &scope)]); + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.vis(&text)).clicked() { + go = Some(h.command.clone()); + } + }); + } + if let Some(c) = go { + self.state.go_to(&c); + } + } + + // ---- Under the table ---- + + fn bottom(&mut self, ui: &mut Ui) { + self.prompt(ui); + self.conflict_bar(ui); + self.message(ui); + self.info(ui); + self.footer(ui); + } + + fn prompt(&mut self, ui: &mut Ui) { + let Some(p) = self.state.prompt.clone() else { return }; + let (text, yes, no) = match &p { + Prompt::ResetAll => (self.ui("Restore every default shortcut in this set?"), self.ui("Reset All"), None), + Prompt::DeleteSet(set) => (fill(self.ui("Delete the set “{set}”?"), &[("set", set)]), self.ui("Delete"), None), + Prompt::SaveChanges { set, .. } => { + (fill(self.ui("Save changes to “{set}”?"), &[("set", set)]), self.ui("Save"), Some(self.ui("Don't Save"))) + } + }; + ui.add(Label::new(RichText::new(self.vis(&text)).strong()).wrap()); + let mut answer = None; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &yes, "shortcuts.prompt.yes") { + answer = Some(Answer::Yes); + } + if let Some(no) = &no + && self.button(ui, true, no, "shortcuts.prompt.no") + { + answer = Some(Answer::No); + } + if self.button(ui, true, &self.ui("Cancel"), "shortcuts.prompt.cancel") { + answer = Some(Answer::Cancel); + } + }); + if let Some(a) = answer { + self.state.answer(self.env, a); + } + } + + fn conflict_bar(&mut self, ui: &mut Ui) { + let Some(c) = self.state.conflict.clone() else { return }; + let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); + let r = ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); + let mut act = 0; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { + act = 1; + } + if self.button(ui, true, &self.ui("Take It and Go to Conflict"), "shortcuts.conflict.goto") { + act = 2; + } + if self.env.options.allow_keep && self.button(ui, true, &self.ui("Keep Both"), "shortcuts.conflict.keep") { + act = 3; + } + let label = self.ui("Cancel the new shortcut"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.conflict.cancel".into(), r.rect, &label); + if r.clicked() { + act = 4; + } + }); + match act { + 1 => self.state.take_it(self.env), + 2 => self.state.take_it_and_go(self.env), + 3 => self.state.keep_both(self.env), + 4 => self.state.dismiss_conflict(), + _ => {} + } + } + + fn message(&mut self, ui: &mut Ui) { + let Some(m) = self.state.message.clone() else { return }; + let mut act = 0; + ui.horizontal_wrapped(|ui| { + let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); + let text = if m.refused { self.warned(text) } else { text }; + let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; + let r = ui.add(Label::new(RichText::new(text).color(color)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); + if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { + act = 1; + } + if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { + act = 2; + } + let label = self.ui("Dismiss"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.message.dismiss".into(), r.rect, &label); + if r.clicked() { + act = 3; + } + }); + match (act, &m.goto) { + (1, Some(c)) => self.state.go_to(c), + (2, _) => self.state.undo(self.env), + (3, _) => self.state.message = None, + _ => {} + } + } + + /// Clashes, shadows and problems in the keymap as a whole. + fn info(&mut self, ui: &mut Ui) { + let built = self.state.lines(self.env); + let (clashes, shadows) = (built.clashes, built.shadows); + let problems = self.env.keymap.problems(); + if clashes == 0 && shadows == 0 && problems.is_empty() { + return; + } + let mut conflicts = false; + ui.horizontal_wrapped(|ui| { + if clashes > 0 { + let n = clashes as u64; + let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); + conflicts = ui.link(self.warned(self.vis(&text))).clicked(); + } + if shadows > 0 { + let n = shadows as u64; + let text = self.env.text.plural(n, "{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + }); + if conflicts { + self.state.set_filter(Filter::Conflicts); + } + if !problems.is_empty() { + let n = problems.len() as u64; + let title = self.env.text.plural(n, "{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"); + egui::CollapsingHeader::new(self.vis(&title)).id_salt(self.base.with("problems")).show(ui, |ui| { + ScrollArea::vertical().max_height(self.row_h * 4.0).show(ui, |ui| { + for p in &problems { + ui.add(Label::new(self.vis(&self.problem(p))).wrap()); + } + }); + }); + } + } + + fn problem(&self, p: &Problem) -> String { + match p { + Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), + Problem::BadBinding { key, text, error } => { + let error = binding_error(self.env, error); + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error)]) + } + Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), + Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), + Problem::DuplicateKey { key } => fill(self.ui("{key}: listed more than once"), &[("key", key)]), + Problem::Dropped { key, .. } => fill(self.ui("{key}: part of the entry was dropped"), &[("key", key)]), + Problem::TooManyCommands { ignored } => { + self.env.text.plural(*ignored as u64, "{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands") + } + } + } + + fn footer(&mut self, ui: &mut Ui) { + let editable = self.env.options.editable; + let right: Vec<(String, &str)> = if editable { + vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] + } else { + vec![(self.ui("Close"), "shortcuts.close")] + }; + let idle = !self.state.modal(); + ui.horizontal(|ui| { + if editable { + if self.button(ui, idle && self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + self.state.undo(self.env); + } + if self.button(ui, idle && self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + self.state.redo(self.env); + } + if self.button(ui, idle && !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + self.state.ask_reset_all(); + } + } + // With sets, Summarize… is in the set row, which a read-only dialog does not show. + if (self.env.sets.is_none() || !editable) && self.env.options.summary { + let label = self.ui("Summarize…"); + if self.button(ui, true, &label, "shortcuts.summarize") { + self.state.summarize(self.env); + } + } + let gap = ui.spacing().item_spacing.x; + let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); + ui.add_space((ui.available_width() - width).max(0.0)); + for (text, id) in &right { + // OK waits for the open prompt or review; Cancel and Close never do. + if self.button(ui, idle || *id != "shortcuts.ok", text, id) { + if editable && *id == "shortcuts.ok" { + self.state.ok(self.env); + } else { + self.state.cancel(self.env); + } + } + } + }); + } +} + +/// Separator between menu path parts, mirrored by [`Draw::ordered`]. +const PATH_SEP: &str = " › "; + +/// Places rectangles laid out left to right inside a span, mirrored in a right-to-left interface. +#[derive(Clone, Copy)] +struct Mirror { + on: bool, + /// The span's left plus right edge. + sum: f32, +} + +impl Mirror { + fn rect(self, r: Rect) -> Rect { + if self.on { Rect::from_x_y_ranges((self.sum - r.max.x)..=(self.sum - r.min.x), r.y_range()) } else { r } + } + + /// The left edge of something `width` wide whose left-to-right left edge is `x`. + fn x(self, x: f32, width: f32) -> f32 { + if self.on { self.sum - x - width } else { x } + } +} + +// ---- Icons, drawn so they do not depend on the app's fonts ---- + +fn square(r: Rect, f: f32) -> Rect { + Rect::from_center_size(r.center(), Vec2::splat(r.width().min(r.height()) * f)) +} + +fn paint_plus(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.5); + let s = Stroke::new(1.5, c); + p.line_segment([r.center_top(), r.center_bottom()], s); + p.line_segment([r.left_center(), r.right_center()], s); +} + +fn paint_cross(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.45); + let s = Stroke::new(1.5, c); + p.line_segment([r.left_top(), r.right_bottom()], s); + p.line_segment([r.right_top(), r.left_bottom()], s); +} + +fn paint_reset(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.55); + let (center, radius) = (r.center(), r.width() / 2.0); + let s = Stroke::new(1.5, c); + let at = |a: f32| center + radius * Vec2::angled(a); + let start = -std::f32::consts::FRAC_PI_2; + let points: Vec<_> = (0..=12).map(|i| at(start + i as f32 / 12.0 * 1.6 * std::f32::consts::PI)).collect(); + p.add(egui::Shape::line(points, s)); + let tip = at(start); + p.line_segment([tip, tip + vec2(radius * 0.6, -radius * 0.35)], s); + p.line_segment([tip, tip + vec2(radius * 0.5, radius * 0.45)], s); +} + +fn paint_keyboard(p: &Painter, r: Rect, c: Color32) { + let r = Rect::from_center_size(r.center(), vec2(r.width() * 0.7, r.height() * 0.5)); + p.rect_stroke(r, 2.0, Stroke::new(1.2, c), StrokeKind::Inside); + for row in 0..2 { + for col in 0..4 { + let x = r.min.x + r.width() * (col as f32 + 0.75) / 4.5; + let y = r.min.y + r.height() * (row as f32 + 1.0) / 4.0; + p.circle_filled(pos2(x, y), 0.9, c); + } + } + p.line_segment( + [pos2(r.min.x + r.width() * 0.3, r.max.y - r.height() * 0.22), pos2(r.max.x - r.width() * 0.3, r.max.y - r.height() * 0.22)], + Stroke::new(1.2, c), + ); +} diff --git a/crates/keymap/tests/chord.rs b/crates/keymap/tests/chord.rs new file mode 100644 index 00000000..116b357e --- /dev/null +++ b/crates/keymap/tests/chord.rs @@ -0,0 +1,178 @@ +use craft_keymap::chord::foreign::{Flavor, ForeignError, ReadNote, read_foreign}; +use craft_keymap::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn mods(cmd: bool, ctrl: bool, alt: bool, shift: bool) -> Mods { + Mods { cmd, ctrl, alt, shift } +} + +#[test] +fn canonical_text_round_trips_for_every_key_and_modifier_set() { + for &key in Key::ALL { + for bits in 0..16u8 { + let c = Chord::new(mods(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0), key); + let text = c.to_string(); + assert_eq!(Chord::parse(&text), Ok(c), "{text}"); + assert_eq!(Trigger::parse(&format!("{text} {text}")), Ok(Trigger::double(c)), "{text} double"); + let json = serde_json::to_string(&Trigger::single(c)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Trigger::single(c)); + // macOS display text reads back as the same chord. + assert_eq!(Chord::parse(&c.display(Platform::Mac)), Ok(c), "{}", c.display(Platform::Mac)); + } + } + assert_eq!(chord("Cmd+Ctrl+Alt+Shift+K").to_string(), "Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(serde_json::to_string(&chord("shift+cmd+k")).unwrap(), "\"Cmd+Shift+K\""); +} + +#[test] +fn aliases_parse_to_canonical_chords() { + let cases = [ + ("cmd+shift+z", "Cmd+Shift+Z"), + ("Shift+Command+Z", "Cmd+Shift+Z"), + ("meta+z", "Cmd+Z"), + ("Super+Z", "Cmd+Z"), + ("mod+z", "Cmd+Z"), + ("⌘⇧Z", "Cmd+Shift+Z"), + ("⌃⌥⇧⌘K", "Cmd+Ctrl+Alt+Shift+K"), + ("⌘+K", "Cmd+K"), + ("⌘Shift+K", "Cmd+Shift+K"), + ("control+opt+left", "Ctrl+Alt+Left"), + ("ctl+option+ArrowUp", "Ctrl+Alt+Up"), + // craft-menu writes Ctrl before Cmd. + ("Ctrl+Cmd+Alt+Shift+M", "Cmd+Ctrl+Alt+Shift+M"), + ("Ctrl+Cmd+F", "Cmd+Ctrl+F"), + ("Cmd++", "Cmd++"), + ("⌘+", "Cmd++"), + ("+", "+"), + ("Cmd+Plus", "Cmd++"), + ("cmd+Equals", "Cmd+="), + ("Cmd+-", "Cmd+-"), + ("Cmd + K", "Cmd+K"), + ("esc", "Escape"), + ("return", "Enter"), + ("del", "Delete"), + ("pgup", "PageUp"), + ("pgdn", "PageDown"), + ("arrowdown", "Down"), + ("spacebar", "Space"), + ("ins", "Insert"), + ("f12", "F12"), + ("Num5", "5"), + ("Semicolon", ";"), + ("Questionmark", "?"), + ("OpenCurlyBracket", "{"), + ("Backtick", "`"), + ("⌘⌫", "Cmd+Backspace"), + ]; + for (text, canonical) in cases { + assert_eq!(chord(text).to_string(), canonical, "{text}"); + } +} + +#[test] +fn hostile_text_is_refused() { + let long = "Cmd+".repeat(MAX_CHORD_TEXT / 4) + "K"; + assert!(long.len() > MAX_CHORD_TEXT); + let cases: &[(&str, ParseError)] = &[ + ("", ParseError::Empty), + (" ", ParseError::Empty), + (&long, ParseError::TooLong), + ("+++", ParseError::UnknownModifier("+".into())), + ("++", ParseError::MissingKey), + ("Cmd+", ParseError::MissingKey), + ("Cmd", ParseError::MissingKey), + ("⌘", ParseError::MissingKey), + ("Shift+Alt", ParseError::MissingKey), + ("Cmd+Command+K", ParseError::DuplicateModifier("Command".into())), + ("⌘⌘K", ParseError::DuplicateModifier("⌘".into())), + ("Cmd+é", ParseError::UnknownKey("é".into())), + ("Ctrl+ж+K", ParseError::UnknownModifier("ж".into())), + ("Hyper+K", ParseError::UnknownModifier("Hyper".into())), + ("A B", ParseError::Sequence), + ("Cmd+K Cmd+J", ParseError::Sequence), + ("A A A", ParseError::UnknownKey("A A A".into())), + ]; + for (text, err) in cases { + assert_eq!(Trigger::parse(text).as_ref(), Err(err), "{text:?}"); + } + let wide = "⌘".repeat(30); + assert_eq!(Chord::parse(&wide), Err(ParseError::TooLong)); + assert!(serde_json::from_str::("\"Cmd+Nope\"").is_err()); +} + +#[test] +fn display_follows_the_platform() { + let c = chord("Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(c.display(Platform::Mac), "⌃⌥⇧⌘K"); + assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K"); + assert_eq!(chord("Ctrl+Up").display(Platform::Mac), "⌃↑"); + assert_eq!(chord("Ctrl+Up").display(Platform::Linux), "Ctrl+Up"); + assert_eq!(chord("Cmd+Enter").display(Platform::Mac), "⌘⏎"); + assert_eq!(chord("Shift+Tab").display(Platform::Mac), "⇧⇥"); + assert_eq!(chord("Space").display(Platform::Mac), "Space"); + assert_eq!(chord("Alt+PageDown").display(Platform::Windows), "Alt+Page Down"); + assert_eq!(Trigger::parse("Cmd+K Cmd+K").unwrap().display(Platform::Mac), "⌘K ⌘K"); + assert_eq!(Trigger::parse("A A").unwrap().to_string(), "A A"); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let ctrl = chord("Ctrl+S"); + let cmd = chord("Cmd+S"); + assert_ne!(ctrl.effective(Platform::Mac), cmd.effective(Platform::Mac)); + for p in [Platform::Windows, Platform::Linux] { + assert_eq!(ctrl.effective(p), cmd); + assert_eq!(chord("Cmd+Ctrl+S").effective(p), cmd); + } + assert!(ctrl.has_command() && cmd.has_command()); + assert!(chord("Shift+B").is_plain() && !chord("Alt+B").is_plain()); + assert!(chord("Shift+F35").is_function_key() && !chord("F").is_function_key()); +} + +#[test] +fn foreign_windows_text() { + let read = |t: &str| read_foreign(t, Flavor::Windows); + let ok = |t: &str| read(t).unwrap_or_else(|e| panic!("{t}: {e}")); + assert_eq!(ok("Shift+Ctrl+S"), (Trigger::single(chord("Cmd+Shift+S")), vec![])); + assert_eq!(ok("Ctrl+,").0, Trigger::single(chord("Cmd+,"))); + assert_eq!(ok(",").0, Trigger::single(chord(","))); + assert_eq!(ok("Ctrl++").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Ctrl+-").0, Trigger::single(chord("Cmd+-"))); + assert_eq!(ok("Alt-F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl + S").0, Trigger::single(chord("Cmd+S"))); + assert_eq!(ok("Shift + Ctrl + S").0, Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl + +").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Alt - F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl Page Down").0, Trigger::single(chord("Cmd+PageDown"))); + assert_eq!(ok("Shift+Up Arrow").0, Trigger::single(chord("Shift+Up"))); + assert_eq!(ok("Num +"), (Trigger::single(chord("+")), vec![ReadNote::Numpad])); + assert_eq!(ok("Ctrl+Num /"), (Trigger::single(chord("Cmd+/")), vec![ReadNote::Numpad])); + assert_eq!(ok("Numpad 5"), (Trigger::single(chord("5")), vec![ReadNote::Numpad])); + assert_eq!(ok("Num Enter"), (Trigger::single(chord("Enter")), vec![ReadNote::Numpad])); + assert_eq!(read("Win+E"), Err(ForeignError::UnsupportedModifier("Win".into()))); + assert_eq!(read("Meta+E"), Err(ForeignError::UnsupportedModifier("Meta".into()))); + assert_eq!(read(""), Err(ForeignError::Empty)); + assert_eq!(read(&"x".repeat(MAX_CHORD_TEXT + 1)), Err(ForeignError::TooLong)); + assert_eq!(read("Ctrl+Num Lock"), Err(ForeignError::UnknownKey("Num Lock".into()))); + assert_eq!(read("Alt"), Err(ForeignError::UnknownKey("Alt".into()))); +} + +#[test] +fn foreign_mac_text() { + let texts = ["Shift+Cmd+S", "Ctrl+Opt+Left"]; + assert_eq!(Flavor::detect(texts), Flavor::Mac); + assert_eq!(Flavor::detect(["Shift+Ctrl+S", "Alt+F4"]), Flavor::Windows); + assert_eq!(Flavor::detect(["⌥⌘K"]), Flavor::Mac); + let ok = |t: &str| read_foreign(t, Flavor::Mac).unwrap_or_else(|e| panic!("{t}: {e}")).0; + assert_eq!(ok("Shift+Cmd+S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl+Opt+Left"), Trigger::single(chord("Ctrl+Alt+Left"))); + assert_eq!(ok("Control-Option-Space"), Trigger::single(chord("Ctrl+Alt+Space"))); + assert_eq!(ok("⇧⌘S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("⌃⌥⇞"), Trigger::single(chord("Ctrl+Alt+PageUp"))); + assert_eq!(ok("Cmd+,"), Trigger::single(chord("Cmd+,"))); + // In a Windows set the same ⌃ is the command modifier. + assert_eq!(read_foreign("⌃S", Flavor::Windows).map(|r| r.0), Ok(Trigger::single(chord("Cmd+S")))); +} diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..02a44052 --- /dev/null +++ b/crates/keymap/tests/editor.rs @@ -0,0 +1,200 @@ +use craft_keymap::editor::{MAX_UNDO_BYTES, holders, search}; +use craft_keymap::{ + AssignError, Bindable, Binding, Chord, CommandRef, DOUBLE_PRESS, EditSession, Holder, Keymap, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, + MAX_UNDO, Overrides, Platform, Policy, Recorded, Recorder, Scope, Scopes, Trigger, +}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap(platform: Platform) -> Keymap { + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), + Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), + Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), + ]; + Keymap::new(catalog, Scopes::new(), platform) +} + +fn keys(km: &Keymap, id: &str) -> Vec { + km.bindings(&CommandRef::new(id)).iter().map(|b| b.to_string()).collect() +} + +#[test] +fn edits_undo_redo_and_cancel() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + + let assigned = session.edit(&mut km, |km| km.assign(&save, b("Cmd+Shift+S"), Policy::Steal)); + assert!(assigned.is_ok()); + session.edit(&mut km, |km| km.clear(&CommandRef::new("edit.copy"))); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + assert!(keys(&km, "edit.copy").is_empty()); + assert!(session.is_modified(&km)); + + // An edit that changes nothing is no undo step. + session.edit(&mut km, |km| km.reset(&CommandRef::new("tool.brush"))); + + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert!(!session.undo(&mut km)); + assert!(!session.is_modified(&km)); + + assert!(session.redo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + // A new edit drops what could be redone. + session.edit(&mut km, |km| km.clear(&CommandRef::new("tool.brush"))); + assert!(!session.redo(&mut km)); + + session.cancel(&mut km); + assert!(!session.is_modified(&km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert_eq!(keys(&km, "tool.brush"), ["B"]); + assert!(!session.can_undo()); +} + +#[test] +fn undo_keeps_the_newest_steps() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let brush = CommandRef::new("tool.brush"); + // Each edit changes the key: N, B, N, … (B is the default). + let edits = MAX_UNDO + 11; + for i in 0..edits { + let key = if i % 2 == 0 { "N" } else { "B" }; + assert!(session.edit(&mut km, |km| km.set_bindings(&brush, vec![b(key)], Policy::Keep)).is_ok()); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert_eq!(steps, MAX_UNDO); + // The 11 oldest steps are gone: undo stops after the 11th edit, which set N. + assert_eq!(keys(&km, "tool.brush"), ["N"]); + assert!(session.is_modified(&km)); +} + +#[test] +fn undo_history_is_capped_by_size() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + // Full-size snapshots of entries for commands the catalog does not have. + let big = |tag: char| { + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS])); + } + o + }; + let (a, b) = (big('a'), big('b')); + let string = size_of::(); + let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::()).sum::(); + let edits = 2 * MAX_UNDO_BYTES / per + 4; + for n in 0..edits { + let next = if n % 2 == 0 { a.clone() } else { b.clone() }; + session.edit(&mut km, |km| km.set_overrides(next)); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert!(steps >= 2 && steps <= MAX_UNDO_BYTES / per, "{steps} steps of {per} bytes"); +} + +#[test] +fn recorder_turns_presses_into_triggers() { + let mut r = Recorder::new(); + let a = chord("A"); + assert_eq!(r.press(a, 1.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 1.0 + DOUBLE_PRESS / 2.0), Recorded::Double(Trigger::double(a))); + // The press after a double starts over. + assert_eq!(r.press(a, 1.4), Recorded::Single(Trigger::single(a))); + // Too late for a double. + assert_eq!(r.press(a, 1.4 + DOUBLE_PRESS + 0.1), Recorded::Single(Trigger::single(a))); + // Another chord in between. + assert_eq!(r.press(chord("B"), 3.0), Recorded::Single(Trigger::single(chord("B")))); + assert_eq!(r.press(a, 3.1), Recorded::Single(Trigger::single(a))); + + assert_eq!(r.press(chord("Escape"), 3.2), Recorded::Cancel); + let shift_escape = chord("Shift+Escape"); + assert_eq!(r.press(shift_escape, 3.3), Recorded::Single(Trigger::single(shift_escape))); + // Two presses in one egui frame share its time. + assert_eq!(r.press(a, 4.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 4.0), Recorded::Double(Trigger::double(a))); +} + +#[test] +fn search_reads_labels_aliases_paths_ids_and_keys() { + let ids = |km: &Keymap, q: &str, p: Platform| search(km, q, p).iter().map(|b| b.command.id.clone()).collect::>(); + let win = keymap(Platform::Windows); + let mac = keymap(Platform::Mac); + assert_eq!(ids(&win, "zoom", Platform::Windows), ["view.zoomIn"]); + assert_eq!(ids(&win, "KOPIEREN", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&win, "paint", Platform::Windows), ["tool.brush"]); + assert_eq!(ids(&win, "file.sa", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "ctrl+s", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "cmd+c", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&mac, "⌘s", Platform::Mac), ["file.save"]); + assert_eq!(ids(&mac, "⌘s", Platform::Windows), Vec::::new()); + assert_eq!(ids(&mac, " ", Platform::Mac).len(), 4); +} + +#[test] +fn a_dialog_asks_before_stealing_and_undoes_each_answer() { + let mut km = keymap(Platform::Mac); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + let copy = CommandRef::new("edit.copy"); + let held = vec![Holder { command: copy.clone(), binding: b("Cmd+C") }]; + + // While recording: who else has the key. + assert_eq!(holders(&km, &save, &b("Cmd+C")), held); + assert!(holders(&km, ©, &b("Cmd+C")).is_empty(), "its own key"); + assert_eq!(session.assign(&mut km, &save, b("Cmd+C"), Policy::Refuse), Err(AssignError::Taken(held.clone()))); + assert!(!session.can_undo(), "a refused assignment changes nothing"); + + // Keep: both hold it. + let kept = session.assign(&mut km, &save, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(kept.clashes, held); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + + // Steal: the other command loses it. + let stolen = session.assign(&mut km, &save, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(stolen.taken_from, held); + assert!(keys(&km, "edit.copy").is_empty()); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + + // Recording over one key of the list replaces it in place. + session.replace(&mut km, &save, &b("Cmd+S"), b("Cmd+Shift+S"), Policy::Refuse).unwrap(); + assert_eq!(keys(&km, "file.save"), ["Cmd+Shift+S", "Cmd+C"]); + assert!(session.remove(&mut km, &save, &b("Cmd+C"))); + assert!(session.clear(&mut km, &CommandRef::new("tool.brush"))); + + assert!(session.reset(&mut km, &save)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + session.reset_all(&mut km); + assert!(km.overrides().is_empty()); + assert!(!session.is_modified(&km)); + + // One undo per action, back to the steal. + for _ in 0..5 { + assert!(session.undo(&mut km)); + } + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + assert!(session.undo(&mut km)); + assert!(!session.can_undo()); + + session.redo(&mut km); + session.cancel(&mut km); + assert!(km.overrides().is_empty(), "cancel restores the state at open"); +} diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs new file mode 100644 index 00000000..e5364518 --- /dev/null +++ b/crates/keymap/tests/egui.rs @@ -0,0 +1,573 @@ +#![cfg(feature = "egui")] + +use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, KeyInput, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; +use egui::{Event, InputState, Key as EguiKey, Modifiers}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +#[test] +fn a_browser_on_a_mac_gets_the_mac_keymap() { + let ctx = egui::Context::default(); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15")); + assert_eq!(platform(&ctx), Platform::Mac); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")); + assert_eq!(platform(&ctx), Platform::Windows); +} + +#[test] +fn every_key_converts_both_ways() { + for &key in Key::ALL { + assert_eq!(Key::from_egui(key.to_egui()), Some(key), "{key}"); + } + for k in [EguiKey::Copy, EguiKey::Cut, EguiKey::Paste, EguiKey::BrowserBack, EguiKey::ShiftLeft, EguiKey::IntlBackslash] { + assert_eq!(Key::from_egui(k), None); + } +} + +#[test] +fn modifiers_and_shortcuts_follow_the_platform() { + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + assert_eq!(Mods::from_egui(&ctrl, Platform::Windows), Mods::CMD); + assert_eq!(Mods::from_egui(&Modifiers::CTRL, Platform::Mac), Mods::CTRL); + assert_eq!(Mods::from_egui(&Modifiers::MAC_CMD, Platform::Mac), Mods::CMD); + + assert!(matches(&chord("Ctrl+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(matches(&chord("Cmd+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(!matches(&chord("Cmd+S"), EguiKey::S, &Modifiers::CTRL, Platform::Mac)); + + let shortcut = chord("Cmd+Shift+Z").to_egui_shortcut(); + assert_eq!(shortcut.logical_key, EguiKey::Z); + assert!(Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE }.matches_exact(shortcut.modifiers)); +} + +#[test] +fn shifted_symbols_match_their_bindings() { + let p = Platform::Windows; + let shift = Modifiers::SHIFT; + let none = Modifiers::NONE; + let cmd = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + let cmd_shift = Modifiers { shift: true, ..cmd }; + + // Shift+base matches the shifted key egui reports, but the base key alone does not. + assert!(matches(&chord("Shift+;"), EguiKey::Colon, &shift, p)); + assert!(!matches(&chord("Shift+;"), EguiKey::Semicolon, &none, p)); + assert!(!matches(&chord(";"), EguiKey::Colon, &shift, p)); + assert!(matches(&chord("Shift+/"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord("Cmd+Shift+]"), EguiKey::CloseCurlyBracket, &cmd_shift, p)); + assert!(matches(&chord("Shift+1"), EguiKey::Exclamationmark, &shift, p)); + // A shifted symbol bound directly matches while Shift is held to type it. + assert!(matches(&chord("?"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord(":"), EguiKey::Colon, &none, p)); + + // Zoom keys: `=` also matches `+`, with or without Shift; `+` matches Shift+`=`. + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd_shift, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Equals, &cmd, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Equals, &cmd_shift, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Plus, &cmd_shift, p)); + assert!(!matches(&chord("Cmd++"), EguiKey::Equals, &cmd, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Plus, &none, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Minus, &cmd, p)); +} + +const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + +fn dispatch_keymap() -> Keymap { + let app = |id: &str, keys: &[&str]| keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(Binding::parse(k).unwrap())); + let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) }); + let catalog = vec![ + app("edit.copy", &["Cmd+C", "Cmd+Insert"]), + app("edit.paste", &["Cmd+V", "Shift+Insert"]), + app("edit.pasteInPlace", &["Cmd+Shift+V"]), + app("edit.copyMerged", &["Cmd+Shift+C"]), + app("app.quit", &["Cmd+Alt+Q"]), + app("type.pageNumber", &["Cmd+Alt+Shift+N"]), + app("view.refresh", &["Cmd+Alt+F5"]), + app("file.save", &["Cmd+S"]), + app("view.zoomIn", &["="]), + app("help.find", &["Shift+;"]), + app("tool.brush", &["B"]), + app("app.loop", &["L"]), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(Binding::parse("L").unwrap()), + tool("tool.marquee"), + tool("tool.ellipse"), + app("reveal.one", &["U"]), + app("reveal.all", &["U U"]), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Windows) +} + +fn press(key: EguiKey, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers } +} + +fn frame(time: f64, modifiers: Modifiers, events: Vec) -> InputState { + let mut input = InputState::default(); + input.time = time; + input.modifiers = modifiers; + input.events = events; + input +} + +/// Dispatches one frame and returns the ids that ran and the events left. +fn run(d: &mut Dispatcher, km: &Keymap, active: &Active, mut input: InputState) -> (Vec, Vec) { + let hits = d.dispatch(&mut input, km, active); + (hits.into_iter().map(|h| h.command.id).collect(), input.events) +} + +#[test] +fn a_bound_press_runs_once_and_is_removed_with_its_text() { + let km = dispatch_keymap(); + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + Event::Key { key: EguiKey::B, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + press(EguiKey::Q, Modifiers::NONE), + Event::Text("q".into()), + ]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events)); + assert_eq!(ran, ["tool.brush"]); + // The release and the unbound Q with its text stay. + assert_eq!(left.len(), 3); + assert!(matches!(left[0], Event::Key { key: EguiKey::B, pressed: false, .. })); + assert!(matches!(left[1], Event::Key { key: EguiKey::Q, pressed: true, .. })); + assert_eq!(left[2], Event::Text("q".into())); +} + +#[test] +fn shifted_symbols_and_zoom_keys_reach_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, shift, vec![press(EguiKey::Colon, shift), Event::Text(":".into())])); + assert_eq!(ran, ["help.find"]); + assert!(left.is_empty()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![press(EguiKey::Plus, Modifiers::NONE)])); + assert_eq!(ran, ["view.zoomIn"]); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, shift, vec![press(EguiKey::Plus, shift)])); + assert_eq!(ran, ["view.zoomIn"]); +} + +#[test] +fn clipboard_events_stay_with_the_app_by_default() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + // egui-winit sends these in place of the Ctrl+C and Ctrl+V presses. + let events = vec![Event::Copy, Event::Paste("text".into())]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + // The web build sends the press and then the event: both stay. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, events.clone())); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, events[..2]); + // A clipboard press with no clipboard event after it is an ordinary press. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn clipboard_chords_with_more_modifiers_run_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + // The desktop: egui-winit sends the Paste event in place of Ctrl+Shift+V. + let mut input = frame(1.0, ctrl_shift, vec![Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.pasteInPlace", Some("text"))]); + assert!(input.events.is_empty()); + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, ctrl_shift, vec![Event::Copy])); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty()); + // Ctrl+Shift+X has no binding: the event stays. + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, ctrl_shift, vec![Event::Cut])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Cut]); + + // The web: the press decides for itself and its event, also while typing. + for (time, active) in [(4.0, &Active::NONE), (5.0, &typing)] { + let events = vec![press(EguiKey::C, ctrl_shift), Event::Copy]; + let (ran, left) = run(&mut d, &km, active, frame(time, ctrl_shift, events)); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty(), "{left:?}"); + } + let events = vec![press(EguiKey::X, ctrl_shift), Event::Cut]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(6.0, ctrl_shift, events.clone())); + assert!(ran.is_empty()); + assert_eq!(left, events); +} + +#[test] +fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let mut input = frame(1.0, CTRL, vec![Event::Copy, Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.copy", None), ("edit.paste", Some("text"))]); + assert!(input.events.is_empty()); + + let typing = Active { typing: true, ..Active::NONE }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, CTRL, vec![Event::Copy])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Copy]); + + // The web build sends the key press and the clipboard event it causes: one run each. + for (time, key, mods, event, id) in [ + (3.0, EguiKey::C, CTRL, Event::Copy, "edit.copy"), + (4.0, EguiKey::Insert, CTRL, Event::Copy, "edit.copy"), + (5.0, EguiKey::Insert, Modifiers::SHIFT, Event::Paste("text".into()), "edit.paste"), + ] { + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(time, mods, vec![press(key, mods), event])); + assert_eq!(ran, [id], "{key:?}"); + } +} + +#[test] +fn a_web_paste_carries_its_text_and_takes_its_event() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + // The web build sends the Cmd+V press and then the Paste event with the clipboard text. + let events = vec![press(EguiKey::V, CTRL), Event::Paste("text".into()), press(EguiKey::C, CTRL), Event::Copy]; + let mut input = frame(1.0, CTRL, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push(hit.pasted.clone()); + true + }); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.paste", Some("text")), ("edit.copy", None)]); + assert_eq!(seen, [Some("text".to_string()), None], "the filter sees the pasted text"); + assert!(input.events.is_empty(), "{:?}", input.events); + + // A declined press leaves the press and its event in place, and the event does not run again. + let mut input = frame(2.0, CTRL, events.clone()); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| hit.command.id != "edit.paste"); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["edit.copy"]); + assert_eq!(input.events, events[..2]); +} + +#[test] +fn a_held_web_paste_runs_once_when_repeats_are_off() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let repeat = Event::Key { key: EguiKey::V, physical_key: Some(EguiKey::V), pressed: true, repeat: true, modifiers: CTRL }; + let paste = || Event::Paste("text".into()); + let events = vec![press(EguiKey::V, CTRL), paste(), repeat.clone(), paste()]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events)); + assert_eq!(ran, ["edit.paste"]); + assert_eq!(left, [repeat, paste()], "the repeat and its event stay in egui's input"); +} + +#[test] +fn declined_hits_leave_their_events_in_order() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + press(EguiKey::S, CTRL), + press(EguiKey::Colon, shift), + Event::Text(":".into()), + press(EguiKey::Equals, Modifiers::NONE), + ]; + let mut input = frame(1.0, Modifiers::NONE, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push((hit.command.id.clone(), hit.trigger.chord.to_string(), hit.exact)); + hit.command.id != "file.save" && hit.command.id != "tool.brush" + }); + let s = |id: &str, chord: &str, exact| (id.to_string(), chord.to_string(), exact); + assert_eq!(seen, [s("tool.brush", "B", true), s("file.save", "Cmd+S", true), s("help.find", "Shift+;", false), s("view.zoomIn", "=", true)]); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["help.find", "view.zoomIn"]); + assert_eq!(input.events, events[..3], "the declined B keeps its text, and Ctrl+S stays in its place"); +} + +#[test] +fn clipboard_presses_stay_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // The web build's Cmd+C press and its Copy event both reach the text field. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::Insert, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn a_press_that_types_text_stays_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // A press the web build's text agent follows with the text it typed. + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into())]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, Modifiers::NONE, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); +} + +#[test] +fn ctrl_alt_is_left_to_the_text_field_only_when_it_types() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // AltGr+Q on a German layout types `@`: the browser on Windows reports Ctrl+Alt+Q and the text + // agent sends the text after it. + let alt_gr = Modifiers { alt: true, ..CTRL }; + let events = vec![press(EguiKey::Q, alt_gr), Event::Text("@".into())]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, alt_gr, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + // A real Ctrl+Alt chord types nothing, so it runs while typing. + let all = Modifiers { shift: true, ..alt_gr }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, all, vec![press(EguiKey::N, all)])); + assert_eq!(ran, ["type.pageNumber"]); + assert!(left.is_empty()); + let (ran, _) = run(&mut d, &km, &typing, frame(3.0, alt_gr, vec![press(EguiKey::Q, alt_gr)])); + assert_eq!(ran, ["app.quit"]); +} + +#[test] +fn typing_blocks_plain_keys_but_not_command_chords() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into()), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left.len(), 2); + assert_eq!(left[1], Event::Text("b".into())); +} + +#[test] +fn undo_goes_to_the_text_field_and_save_still_runs() { + let km = Keymap::new( + vec![ + Bindable::new("edit.undo", "Undo", Scope::app()).with_default(Binding::parse("Cmd+Z").unwrap()), + Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::Z, CTRL), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, [press(EguiKey::Z, CTRL)]); +} + +#[test] +fn the_focused_panel_wins_and_tool_groups_cycle() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let l = || frame(1.0, Modifiers::NONE, vec![press(EguiKey::L, Modifiers::NONE)]); + assert_eq!(run(&mut d, &km, &Active::panel("Timeline"), l()).0, ["timeline.loop"]); + assert_eq!(run(&mut d, &km, &Active::NONE, l()).0, ["app.loop"]); + + let mut input = frame(2.0, Modifiers::NONE, vec![press(EguiKey::M, Modifiers::NONE)]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")]; + let trigger = Trigger::single(chord("M")); + let got: Vec<_> = hits.iter().map(|h: &Hit| (&h.command, &h.cycle, h.trigger, h.exact, h.pasted.as_deref())).collect(); + assert_eq!(got, [(&tools[0], &tools, trigger, true, None)]); +} + +#[test] +fn a_second_press_runs_the_double_binding() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let key = |d: &mut Dispatcher, k: EguiKey, t: f64| run(d, &km, &Active::NONE, frame(t, Modifiers::NONE, vec![press(k, Modifiers::NONE)])).0; + let u = |d: &mut Dispatcher, t: f64| key(d, EguiKey::U, t); + assert_eq!(u(&mut d, 1.0), ["reveal.one"]); + assert_eq!(u(&mut d, 1.3), ["reveal.all"]); + // The press after a double starts over. + assert_eq!(u(&mut d, 1.5), ["reveal.one"]); + assert_eq!(u(&mut d, 2.5), ["reveal.one"]); + // B in between breaks the pair. + key(&mut d, EguiKey::B, 2.6); + assert_eq!(u(&mut d, 2.7), ["reveal.one"]); +} + +#[test] +fn a_double_press_needs_both_presses_in_one_panel() { + let km = Keymap::new( + vec![ + Bindable::new("x.one", "One", Scope::new("X").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.one", "One", Scope::new("Y").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.two", "Two", Scope::new("Y").unwrap()).with_default(Binding::parse("A A").unwrap()), + ], + Scopes::new().with_panel("X").with_panel("Y").with_panel_fallthrough(false), + Platform::Windows, + ); + let mut d = Dispatcher::new(); + let a = |d: &mut Dispatcher, panel, t| run(d, &km, &Active::panel(panel), frame(t, Modifiers::NONE, vec![press(EguiKey::A, Modifiers::NONE)])).0; + assert_eq!(a(&mut d, "X", 1.0), ["x.one"]); + assert_eq!(a(&mut d, "Y", 1.2), ["y.one"]); + assert_eq!(a(&mut d, "Y", 1.4), ["y.two"]); +} + +#[test] +fn two_presses_in_one_frame_make_a_double() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let u = press(EguiKey::U, Modifiers::NONE); + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(4.0, Modifiers::NONE, vec![u.clone(), u])); + assert_eq!(ran, ["reveal.one", "reveal.all"]); + assert!(left.is_empty(), "{left:?}"); +} + +#[test] +fn key_repeats_run_only_when_turned_on() { + let km = Keymap::new( + vec![ + Bindable::new("nudge.right", "Nudge", Scope::app()).with_default(Binding::parse("Right").unwrap()), + Bindable::new("jump.right", "Jump", Scope::app()).with_default(Binding::parse("Right Right").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let held = |repeat: bool| Event::Key { key: EguiKey::ArrowRight, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + let events = || vec![held(false), held(true), held(true)]; + + let mut on = Dispatcher::new().with_repeat(true); + let (ran, left) = run(&mut on, &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right", "nudge.right", "nudge.right"]); + assert!(left.is_empty()); + // Repeats neither made nor broke a double: the next press within the window is one. + let (ran, _) = run(&mut on, &km, &Active::NONE, frame(1.3, Modifiers::NONE, vec![held(false)])); + assert_eq!(ran, ["jump.right"]); + + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right"]); + assert_eq!(left, [held(true), held(true)]); +} + +#[test] +fn the_dispatcher_reads_presses_by_the_layout() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new( + vec![app("edit.duplicate", "Cmd+Shift+D"), app("tab.next", "Cmd+Shift+]"), app("edit.comment", "Cmd+/")], + Scopes::new(), + Platform::Windows, + ); + let at = |key, physical, modifiers| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + + // Arabic 101: typing ي on the D key shows the layout is not Latin, so ⌃⇧D (which types ]) is + // Ctrl+Shift+D. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::D, EguiKey::D, Modifiers::NONE), Event::Text("ي".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert!(!d.layout().is_latin()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); + + // German: ⇧7 types /, so Ctrl+Shift+7 runs Ctrl+/; the ü key's label is learned from typing. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::OpenBracket, EguiKey::OpenBracket, Modifiers::NONE), Event::Text("ü".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert_eq!(chord("Cmd+[").display_for(Platform::Windows, d.layout()), "Ctrl+Ü"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)])); + assert_eq!(ran, ["edit.comment"]); + + // German on egui-winit: AltGr+M types µ and AltGr+] types ~ with no modifiers reported, so + // presses while right Alt is held teach nothing and Ctrl+Z stays Ctrl+Z on the Y position. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::Plus, EguiKey::CloseBracket, Modifiers::NONE), Event::Text("+".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + let right_alt = + |pressed| Event::Key { key: EguiKey::AltRight, physical_key: Some(EguiKey::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE }; + run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![right_alt(true)])); + let alt_graph = vec![ + at(EguiKey::M, EguiKey::M, Modifiers::NONE), + Event::Text("µ".into()), + at(EguiKey::CloseBracket, EguiKey::CloseBracket, Modifiers::NONE), + Event::Text("~".into()), + right_alt(false), + ]; + run(&mut d, &km, &Active::NONE, frame(3.0, Modifiers::NONE, alt_graph)); + assert!(d.layout().is_latin()); + assert_eq!(d.layout().char_at(Key::CloseBracket), Some('+')); + assert_eq!(d.layout().resolve(&KeyInput::new(Mods::CMD).with_typed(Key::Z).with_physical(Key::Y)), Some(chord("Cmd+Z"))); + + // A layout the platform reports applies from the first press. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); +} + +#[test] +fn letters_follow_the_latin_layout_from_egui_winit_to_the_dispatcher() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new(vec![app("d", "Cmd+D"), app("e", "Cmd+E")], Scopes::new(), Platform::Linux); + let at = |key, modifiers| Event::Key { key, physical_key: Some(EguiKey::D), pressed: true, repeat: false, modifiers }; + + // The QWERTY D key, as the suite's egui-winit reports it: on a layout that is not Latin, the + // logical key is the Latin layout's letter (Dvorak types e there), and the text is Cyrillic. + // (layout, logical key, text typed, platform report, command Ctrl runs) + let cases = [ + ("Dvorak + Russian", EguiKey::E, "в", Some(false), "e"), + ("QWERTY + Russian", EguiKey::D, "в", Some(false), "d"), + ("Dvorak", EguiKey::E, "e", Some(true), "e"), + ("QWERTY", EguiKey::D, "d", Some(true), "d"), + ]; + for (name, key, text, latin, expected) in cases { + // The layout learned from typing. + let mut d = Dispatcher::new(); + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, vec![at(key, Modifiers::NONE), Event::Text(text.into())])); + assert_eq!(Some(d.layout().is_latin()), latin, "{name}"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![at(key, CTRL)])); + assert_eq!(ran, [expected], "{name}, learned"); + + // The layout the platform reports, before anything is typed. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(latin == Some(true))); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(key, CTRL)])); + assert_eq!(ran, [expected], "{name}, reported"); + } +} + +/// `input` with the Latin layout characters the suite's egui-winit reports per physical key, set +/// through serde because crates.io egui has no such field; `None` there. +fn with_latin_layout_chars(mut input: InputState, chars: &[(EguiKey, char)]) -> Option { + let mut raw = serde_json::to_value(&input.raw).unwrap(); + raw.get("latin_layout_chars")?; + raw["latin_layout_chars"] = serde_json::to_value(chars).unwrap(); + input.raw = serde_json::from_value(raw).unwrap(); + Some(input) +} + +#[test] +fn a_key_without_a_latin_letter_runs_no_letter_binding() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new(vec![app("quit", "Cmd+Q"), app("quote", "Cmd+'"), app("j", "Cmd+J")], Scopes::new(), Platform::Linux); + let at = |key, physical| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: CTRL }; + + // Dvorak + Russian. The QWERTY Q key types й, and Dvorak ' there: egui-winit reports the key + // as ', and egui without a key for the character as its position, Q. The QWERTY C key types + // с, and Dvorak j. + let latin = [(EguiKey::Q, '\''), (EguiKey::C, 'j')]; + let cases = [(at(EguiKey::Quote, EguiKey::Q), "quote"), (at(EguiKey::Q, EguiKey::Q), "quote"), (at(EguiKey::J, EguiKey::C), "j")]; + for (press, expected) in cases { + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let Some(input) = with_latin_layout_chars(frame(1.0, CTRL, vec![press.clone()]), &latin) else { + // crates.io egui reports no Latin layout, so the position decides: Ctrl+Q. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(EguiKey::Q, EguiKey::Q)])); + assert_eq!(ran, ["quit"]); + return; + }; + let (ran, _) = run(&mut d, &km, &Active::NONE, input); + assert_eq!(ran, [expected], "{press:?}"); + } +} diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs new file mode 100644 index 00000000..60a9f321 --- /dev/null +++ b/crates/keymap/tests/import.rs @@ -0,0 +1,838 @@ +//! Import of synthetic shortcut sets written for these tests. + +use std::collections::BTreeMap; + +use craft_keymap::chord::foreign::ReadNote; +use craft_keymap::chord::foreign::{Flavor, ForeignError}; +use craft_keymap::import::{ + Failed, FailedChange, MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT, MAX_KEYS_PER_ENTRY, MAX_SUGGESTIONS, NoChangeReason, SkipReason, Skipped, + UnmatchedReason, normalize_label, +}; +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport, Keymap, Kind, MAX_COMMANDS, + NoneDefined, Overrides, Platform, Scope, Scopes, Start, plan, reserved, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn bindings(texts: &[&str]) -> Vec { + texts.iter().map(|t| b(t)).collect() +} + +fn command(id: impl Into, label: &str, path: &[&str], keys: &[&str]) -> Bindable { + let mut out = Bindable::new(id, label, Scope::app()); + if !path.is_empty() { + out = out.with_path(path.iter().map(|s| s.to_string()).collect()); + } + keys.iter().fold(out, |e, k| e.with_default(b(k))) +} + +fn panel(name: &str) -> CommandRef { + CommandRef::with_params("window.panel", json!({ "panel": name })) +} + +fn catalog() -> Vec { + vec![ + command("file.new", "Document…", &["File", "New", "Document…"], &["Cmd+N"]), + command("dialog.newDocument", "New Document Dialog", &[], &["Cmd+N"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + command("file.save", "Save", &["File", "Save"], &["Cmd+S"]), + command("file.exit", "Exit", &["File", "Exit"], &[]), + command("edit.paste", "Paste", &["Edit", "Paste"], &["Cmd+V"]), + command("edit.clear", "Clear", &["Edit", "Clear"], &[]), + command("edit.duplicate", "Duplicate", &["Edit", "Duplicate"], &["Cmd+Alt+Shift+D"]), + command("edit.checkSpelling", "Check Spelling…", &["Edit", "Proofing", "Check Spelling…"], &[]), + command("edit.pen", "Pen", &["Edit", "Preferences", "Pen"], &[]), + command("app.palette", "Command Palette", &[], &["Cmd+Alt+K"]), + command(panel("layers"), "Layers", &["Window", "Layers"], &["F7"]), + command(panel("pages"), "Pages", &["Window", "Pages"], &["F12"]), + command("view.guides", "Guides", &["View", "Grids & Guides", "Guides"], &["Cmd+;"]).toggle(), + command("view.goToPage", "Go to Page…", &["View", "Go to Page…"], &["Cmd+J"]), + command("view.zoomIn", "Zoom In", &["View", "Zoom In"], &["Cmd+="]), + command("object.textFrameOptions", "Text Frame Options…", &["Object", "Text Frame Options…"], &["Cmd+B"]), + command("type.break", "Break", &["Type", "Breaks", "Break"], &[]), + command("type.columnBreak", "Insert Break", &["Type", "Column", "Insert Break"], &[]), + command("type.frameBreak", "Insert Break", &["Type", "Frame", "Insert Break"], &[]), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]), + command("type.italic", "Italic", &["Type", "Style", "Italic"], &[]), + command("tool.pen", "Pen Tool", &[], &["P"]).with_kind(Kind::Tool { group: None }).with_alias("Pen"), + command("tool.type", "Type Tool", &[], &["T"]).with_kind(Kind::Tool { group: None }), + command("scripts.run", "Run Script", &[], &[]), + ] +} + +fn keymap(platform: Platform) -> Keymap { + Keymap::new(catalog(), Scopes::new().with_context("Text"), platform).with_reserved(reserved::system(platform)) +} + +fn key(flavor: Flavor, context: &str, text: &str) -> ForeignKey { + ForeignKey::read(Some(context), text, flavor) +} + +fn entry(menu: Option<&str>, path: &[&str], keys: &[(&str, &str)], flavor: Flavor) -> ForeignEntry { + keys.iter().fold(ForeignEntry::new(menu, path.iter().copied()), |e, (c, t)| e.with_key(key(flavor, c, t))) +} + +fn win(menu: &str, path: &[&str], keys: &[(&str, &str)]) -> ForeignEntry { + entry(Some(menu), path, keys, Flavor::Windows) +} + +/// An InDesign-like Windows set, one entry per match rule or apply rule. +fn windows_set() -> ForeignSet { + let d = "Default"; + let entries = vec![ + win("File", &["New", "Document..."], &[(d, "Ctrl+N")]), + win("File", &["Close"], &[(d, "Ctrl+W"), (d, "Ctrl+F4")]), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]), + win("File", &["Exit"], &[(d, "Alt+F4")]), + win("Edit", &["Paste"], &[(d, "Ctrl+E")]), + win("Edit", &["Clear"], &[(d, "Ctrl+E")]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Spelling", "Check Spelling..."], &[(d, "Ctrl+Alt+K"), ("Text", "F7")]), + win("Edit", &["Frobnicate Everything"], &[(d, "Ctrl+F12")]), + win("Edit", &["Never Heard Of It"], &[]), + win("Window", &["Layers"], &[(d, "F7")]), + win("Window", &["Pages"], &[(d, "Shift+F12")]), + win("View", &["Grids & Guides", "SHOW/HIDE Guides"], &[(d, "Ctrl+Alt+;")]), + win("Layout", &["Go To Page"], &[(d, "Ctrl+Alt+J")]), + win("Object", &["Text Frame", "Options"], &[(d, "Ctrl+Alt+B")]), + win("Type", &["Insert", "Break"], &[(d, "Ctrl+Alt+Enter")]), + win("Type", &["Insert Break"], &[(d, "Shift+Enter")]), + win("Type", &["Style", "Bold"], &[("Text", "Shift+Ctrl+B")]), + win("Type", &["Style", "Italic"], &[(d, "Ctrl+I"), ("Table", "Ctrl+Alt+I")]), + win("Tools", &["Pen"], &[(d, "Shift+P")]).tool(), + win("Tools", &["Zoom In"], &[(d, "Z")]).tool(), + entry(None, &["Zoom In"], &[(d, "Ctrl+Alt+I")], Flavor::Windows), + entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), + win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), + ]; + ForeignSet::new(entries).with_name("My Set") +} + +fn numpad_set() -> ForeignSet { + ForeignSet::new(vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)]) +} + +fn windows_options() -> ImportOptions { + let mut remembered = BTreeMap::new(); + remembered.insert(ForeignEntry::new(Some("Scripts"), ["Frobnicate"]).remember_key(), CommandRef::new("scripts.run")); + ImportOptions { + contexts: vec![("default".into(), ContextRule::Home), ("TEXT".into(), ContextRule::HomeWithoutHomeEntry)], + remembered, + ..ImportOptions::default() + } +} + +fn import_windows() -> (Keymap, ImportReport) { + let mut km = keymap(Platform::Windows); + let report = plan(&windows_set(), &km, &windows_options()).apply(&mut km); + (km, report) +} + +fn has(km: &Keymap, id: impl Into, texts: &[&str]) { + let c = id.into(); + assert_eq!(km.bindings(&c), bindings(texts), "{}", c.key()); +} + +#[test] +fn labels_normalize_for_matching() { + assert_eq!(normalize_label("Check Spelling..."), "check spelling"); + assert_eq!(normalize_label("Check Spelling…"), "check spelling"); + assert_eq!(normalize_label("Grids && Guides"), "grids guides"); + assert_eq!(normalize_label("SHOW/HIDE Guides"), "show/hide guides"); + assert_eq!(normalize_label("Preferences: General…"), "preferences general"); + assert_eq!(normalize_label("ÄRGER"), "ärger"); +} + +#[test] +fn every_match_step_finds_its_command() { + let (km, r) = import_windows(); + assert_eq!(r.set.as_deref(), Some("My Set")); + // 1: full path, with `&`, case and spacing normalized, and Show/Hide dropped for a toggle. + has(&km, "view.guides", &["Cmd+Alt+;"]); + // 2: joined segments `Text Frame Options` in Object. + has(&km, "object.textFrameOptions", &["Cmd+Alt+B"]); + // 2 is ambiguous (two `Insert Break` in Type), 3 finds `Break`. + has(&km, "type.break", &["Cmd+Alt+Enter"]); + // 3: last segment in the menu, under another submenu. + has(&km, "edit.checkSpelling", &["Cmd+Alt+K"]); + // 4: the label anywhere; also with no menu for a one-segment path. + has(&km, "view.goToPage", &["Cmd+Alt+J"]); + has(&km, "view.zoomIn", &["Cmd+Alt+I"]); + // 0: a remembered answer. + has(&km, "scripts.run", &["Cmd+Alt+R"]); + // A command with parameters, by its path. + has(&km, panel("pages"), &["Shift+F12"]); + // A tool entry matches the tool, not the command with the same label. + has(&km, "tool.pen", &["Shift+P"]); + has(&km, "edit.pen", &[]); + + let unmatched: Vec<(&str, UnmatchedReason)> = r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect(); + assert_eq!( + unmatched, + [ + ("Edit > Frobnicate Everything", UnmatchedReason::NoMatch), + ("Type > Insert Break", UnmatchedReason::Ambiguous), + ("Type > Style > Italic", UnmatchedReason::Context), + ("Tools > Zoom In", UnmatchedReason::Tool), + ("Somewhere > Zoom In", UnmatchedReason::NoMatch), + ] + ); + assert_eq!(r.unmatched[2].keys, "Table: Ctrl+Alt+I"); + assert_eq!(r.unmatched_without_shortcut, 1); + assert_eq!(r.unreadable.len(), 1); + assert_eq!(r.unreadable[0].item, "File > Save"); + assert_eq!(r.unreadable[0].keys, "Default: Ctrl+Hyperspace"); + assert!(matches!(r.unreadable[0].reason, ForeignError::UnknownKey(_))); + assert!(r.suggestions.is_empty(), "not asked for"); + assert_eq!(r.truncated, 0); +} + +#[test] +fn the_set_wins_and_the_report_says_how() { + let (km, r) = import_windows(); + // Two keys for one command, both kept in order. + has(&km, "file.close", &["Cmd+W", "Cmd+F4"]); + // The Text context counts only without a Default key. + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "type.italic", &["Cmd+I"]); + has(&km, panel("layers"), &["F7"]); + // A later entry takes a chord from an earlier one; the earlier command keeps what it had. + has(&km, "edit.paste", &["Cmd+V"]); + has(&km, "edit.clear", &["Cmd+E"]); + let skipped: Vec<_> = r.skipped.iter().map(|s| (s.command.id.as_str(), s.shortcut.as_str(), s.kept_by.clone(), s.reason)).collect(); + assert_eq!( + skipped, + [("file.exit", "Alt+F4", None, SkipReason::Reserved), ("edit.paste", "Cmd+E", Some(CommandRef::new("edit.clear")), SkipReason::Taken)] + ); + // Taken from a command outside the set; the twin keeps its shared default. + has(&km, "app.palette", &[]); + has(&km, "dialog.newDocument", &["Cmd+N"]); + let cleared: Vec<_> = r.conflicts_cleared.iter().map(|c| (c.command.id.as_str(), c.label.as_str(), c.shortcut.as_str())).collect(); + assert_eq!(cleared, [("app.palette", "Command Palette", "Cmd+Alt+K")]); + // None defined clears; an unreadable key leaves the command as it was. + has(&km, "edit.duplicate", &[]); + has(&km, "file.save", &["Cmd+S"]); + // Bindings equal to the defaults leave no override. + let o = km.overrides(); + assert_eq!(o.get("file.new"), None); + assert_eq!(o.get(&panel("layers").key()), None); + assert_eq!(o.get("edit.duplicate"), Some(&[][..])); + assert_eq!(o.get("file.close"), Some(&["Cmd+W".to_string(), "Cmd+F4".to_string()][..])); + + let changed: Vec<&str> = r.changed.iter().map(|c| c.command.id.as_str()).collect(); + assert_eq!( + changed, + [ + "file.close", + "edit.clear", + "edit.duplicate", + "edit.checkSpelling", + "window.panel", + "view.guides", + "view.goToPage", + "object.textFrameOptions", + "type.break", + "type.bold", + "type.italic", + "tool.pen", + "view.zoomIn", + "scripts.run", + ] + ); + assert_eq!(r.imported, changed.len()); + let close = &r.changed[0]; + assert_eq!((close.label.as_str(), close.item.as_str()), ("Close", "File > Close")); + assert_eq!((close.from.clone(), close.to.clone()), (vec!["Cmd+W".to_string()], vec!["Cmd+W".to_string(), "Cmd+F4".to_string()])); +} + +#[test] +fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { + let mut km = keymap(Platform::Mac); + let mac = |menu: &str, path: &[&str], keys: &[(&str, &str)]| entry(Some(menu), path, keys, Flavor::Mac); + let set = ForeignSet::new(vec![ + mac("File", &["Save"], &[("Default", "⇧⌘S")]), + mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]), + mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), + mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), + ]) + .with_name("Mac"); + let text = Scope::new("Text").unwrap(); + let options = ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home), ("Text".into(), ContextRule::Scope(text))], + ..ImportOptions::default() + }; + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.save", &["Cmd+Shift+S"]); + has(&km, "edit.paste", &["Cmd+Ctrl+V"]); + has(&km, "type.italic", &["Text: Cmd+Shift+I"]); + has(&km, "type.bold", &["Cmd+B"]); + has(&km, "object.textFrameOptions", &[]); + assert_eq!(r.conflicts_cleared[0].command.id, "object.textFrameOptions"); + assert_eq!(r.unmatched.len(), 1); + assert_eq!((r.unmatched[0].keys.as_str(), r.unmatched[0].reason), ("Tables: Cmd+Opt+B", UnmatchedReason::Context)); + assert_eq!(r.changed.iter().find(|c| c.command.id == "type.italic").map(|c| c.to.clone()), Some(vec!["Text: Cmd+Shift+I".to_string()])); +} + +#[test] +fn start_and_none_defined_options() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])]); + let user = || { + let mut o = Overrides::new(); + o.insert("file.save", vec!["Cmd+Alt+S".into()]); + o + }; + let options = |start, none_defined| ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home)], + start, + none_defined, + ..ImportOptions::default() + }; + + let mut km = keymap(Platform::Windows).with_overrides(user()); + plan(&set, &km, &options(Start::Current, NoneDefined::Keep)).apply(&mut km); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + + let mut km = keymap(Platform::Windows).with_overrides(user()); + let r = plan(&set, &km, &options(Start::Defaults, NoneDefined::Clear)).apply(&mut km); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &[]); + assert_eq!(r.imported, 2); +} + +#[test] +fn a_failed_change_keeps_its_shortcut_when_a_later_change_frees_room() { + // Save's change finds the overrides full. Close's change then frees an entry, and Zoom In + // takes Cmd+S. Removing Cmd+S from Save would fit now, but Save's import failed, so Save + // keeps it and Zoom In stays as it was. + let set = ForeignSet::new(vec![ + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("File", &["Close"], &[("Default", "Ctrl+W")]), + win("View", &["Zoom In"], &[("Default", "Ctrl+S")]), + ]); + let mut o = Overrides::new(); + o.insert("file.close", vec!["Cmd+Q".into()]); + o.insert("view.zoomIn", vec!["Cmd+Alt+Z".into()]); + for n in 2..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec!["Cmd+K".into()]); + } + assert_eq!(o.len(), MAX_COMMANDS); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + let mut km = keymap(Platform::Windows).with_overrides(o); + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "file.close", &["Cmd+W"]); + has(&km, "view.zoomIn", &["Cmd+Alt+Z"]); + let failed: Vec<(&str, FailedChange)> = r.failed.iter().map(|f| (f.command.id.as_str(), f.change)).collect(); + assert_eq!(failed, [("file.save", FailedChange::Bind), ("view.zoomIn", FailedChange::Bind)]); + assert!(r.conflicts_cleared.is_empty(), "{:?}", r.conflicts_cleared); + assert_eq!(r.imported, 1); +} + +#[test] +fn changes_that_find_the_overrides_full_are_reported() { + let set = ForeignSet::new(vec![ + win("File", &["Close"], &[("Default", "Ctrl+J")]), + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("Edit", &["Duplicate"], &[]), + ]); + let full = || { + let mut o = Overrides::new(); + o.insert("file.close", vec!["Cmd+Q".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec!["Cmd+K".into()]); + } + assert_eq!(o.len(), MAX_COMMANDS); + o + }; + let options = |start| ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start, ..ImportOptions::default() }; + + // file.close has an entry to rewrite, but taking Cmd+J from view.goToPage needs a new one, so + // both stay as they were. The other changes each need a new entry too. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "view.goToPage", &["Cmd+J"]); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + let rows = |failed: &[Failed]| -> Vec<(String, String, FailedChange, Vec)> { + failed.iter().map(|f| (f.command.id.clone(), f.label.clone(), f.change, f.shortcuts.clone())).collect() + }; + let row = |id: &str, label: &str, change, shortcuts: &[&str]| { + (id.to_string(), label.to_string(), change, shortcuts.iter().map(|s| s.to_string()).collect::>()) + }; + assert_eq!( + rows(&r.failed), + [ + row("file.close", "Close", FailedChange::Bind, &["Cmd+J"]), + row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), + row("edit.duplicate", "Duplicate", FailedChange::Clear, &[]), + ] + ); + assert_eq!(r.imported, 0); + assert!(r.conflicts_cleared.is_empty()); + let v = serde_json::to_value(&r).unwrap(); + assert_eq!( + v["failed"][1], + json!({"command": {"id": "file.save", "params": null}, "label": "Save", "change": "bind", "shortcuts": ["Cmd+Alt+S"]}) + ); + + // Starting from the defaults drops the entries, so everything fits. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Defaults)).apply(&mut km); + assert!(r.failed.is_empty(), "{:?}", r.failed); + has(&km, "file.close", &["Cmd+J"]); + has(&km, "view.goToPage", &[]); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "edit.duplicate", &[]); + + // A later entry whose change finds the overrides full leaves the chord with the earlier one. + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])]); + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+Alt+J"]); + has(&km, "file.save", &["Cmd+S"]); + assert_eq!(rows(&r.failed), [row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+J"])]); + assert!(r.skipped.is_empty(), "{:?}", r.skipped); + assert_eq!(r.imported, 1); +} + +#[test] +fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])]); + let options = ImportOptions { + contexts: vec![ + ("Default".into(), ContextRule::Home), + ("Story".into(), ContextRule::Scope(Scope::new("Story").unwrap())), + ("Text".into(), ContextRule::Scope(Scope::new("Text").unwrap())), + ], + ..ImportOptions::default() + }; + let mut km = keymap(Platform::Windows); + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.close", &["Cmd+Q", "Text: Cmd+L"]); + let skipped: Vec<_> = r + .skipped + .iter() + .map(|s: &Skipped| (s.command.id.as_str(), s.item.as_str(), s.line, s.shortcut.as_str(), s.kept_by.clone(), s.reason)) + .collect(); + assert_eq!(skipped, [("file.close", "File > Close", None, "Story: Cmd+K", None, SkipReason::UnknownScope)]); + assert_eq!(serde_json::to_value(r.skipped[0].reason).unwrap(), "unknownScope"); +} + +#[test] +fn suggestions_are_reported_never_applied() { + let mut km = keymap(Platform::Windows); + let set = ForeignSet::new(vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])]); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let p = plan(&set, &km, &options); + assert!(p.commands.is_empty()); + let r = p.apply(&mut km); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].item, "Edit > Chek Spelling Now"); + assert!(r.suggestions[0].candidates.contains(&CommandRef::new("edit.checkSpelling")), "{:?}", r.suggestions); + has(&km, "edit.checkSpelling", &[]); + assert!(km.overrides().is_empty()); + assert_eq!(r.imported, 0); +} + +#[test] +fn entries_the_reader_dropped_count_as_truncated() { + let set = ForeignSet::new(vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])]).with_truncated(7); + assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7); +} + +#[test] +fn hostile_sets_are_capped_and_counted() { + let mut km = keymap(Platform::Windows); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + // Unmatched entries with keys, more than the suggestions take. + let near = win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+F12")]); + let mut entries: Vec = std::iter::repeat_n(near, MAX_SUGGESTIONS + 10).collect(); + entries.push(big); + entries.push(win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + // Entries without keys fill the set to the cap and 500 past it. + let keyless = MAX_IMPORT_ENTRIES - entries.len(); + entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); + let set = ForeignSet::new(entries).with_name(long.clone()); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); + // The set name, entries and keys past the caps, and suggestions past the cap (with the big entry's). + assert!(r.truncated >= 1 + 500 + 10 + 11, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, keyless); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn hostile_sets_finish_in_time() { + // A full catalog whose labels share words with every unmatched entry below, so each + // suggestion scores every command. + let mut catalog = catalog(); + let filler = MAX_COMMANDS - catalog.len(); + catalog.extend((0..filler).map(|i| command(format!("filler.c{i}").as_str(), &format!("Filler Command {i}"), &[], &[]))); + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + // Odd entries have keys and long labels that match nothing, so they reach the suggestions: + // words in the whole label, or two words and symbols, which score high enough to suggest. + let wordy = "Filler Command ä€𝄞 ".repeat(MAX_IMPORT_TEXT / 8); + let symbols = format!("Filler Command {}", "€𝄞 ".repeat(MAX_IMPORT_TEXT)); + let filler_entry = |i: usize| match i % 4 { + 1 => win("Edit", &[&symbols], &[("Default", "Ctrl+F12")]), + 3 => win("Edit", &[&format!("{wordy}{i}")], &[("Default", "Ctrl+F12")]), + _ => win("Edit", &[&format!("Item {i}")], &[]), + }; + let mut entries: Vec = (0..MAX_IMPORT_ENTRIES + 500).map(filler_entry).collect(); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + entries.insert(0, big); + entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + let set = ForeignSet::new(entries).with_name(long.clone()); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let started = std::time::Instant::now(); + let r = plan(&set, &km, &options).apply(&mut km); + assert!(started.elapsed() < std::time::Duration::from_secs(240), "{:?}", started.elapsed()); + // Read filler entries are those before the cap, after the two inserted ones. + let read = MAX_IMPORT_ENTRIES - 2; + let keyed = read / 2; + assert!(!r.suggestions.is_empty() && r.suggestions.len() <= MAX_SUGGESTIONS, "{}", r.suggestions.len()); + assert!(r.truncated >= 500 + 10 + keyed + 1 - MAX_SUGGESTIONS, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, read - keyed); +} + +#[test] +fn rows_carry_source_lines_and_approximated_keys() { + let mut km = keymap(Platform::Windows); + let d = "Default"; + let set = ForeignSet::new(vec![ + win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3), + win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4), + win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5), + win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), + ]); + let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + // A keypad key binds the main key and is reported. Paste, a later entry, takes Cmd+5. + has(&km, panel("layers"), &["7", "Shift+F7"]); + has(&km, "edit.paste", &["Cmd+5"]); + let approximated: Vec<_> = r.approximated.iter().map(|a| (a.item.as_str(), a.line, a.keys.as_str(), a.note)).collect(); + assert_eq!( + approximated, + [ + ("Window > Layers", Some(3), "Default: Ctrl+Num 5, Default: Num 7", ReadNote::Numpad), + ("Edit > Paste", Some(5), "Default: Ctrl+Num 5", ReadNote::Numpad) + ] + ); + // Merged entries report the first one's line. + let layers = r.changed.iter().find(|c| c.command == panel("layers")).unwrap(); + assert_eq!(layers.line, Some(3)); + assert_eq!((r.skipped[0].item.as_str(), r.skipped[0].line), ("Window > Layers", Some(3))); + assert_eq!(r.unmatched[0].line, Some(8)); + assert_eq!(r.suggestions[0].line, Some(8)); + assert_eq!(r.unreadable[0].line, Some(9)); +} + +#[test] +fn report_json_uses_camel_case_names() { + let mut km = keymap(Platform::Windows); + let mut options = windows_options(); + options.suggest = true; + let r = plan(&windows_set(), &km, &options).apply(&mut km); + let v = serde_json::to_value(&r).unwrap(); + let names = |v: &serde_json::Value| v.as_object().unwrap().keys().cloned().collect::>(); + let mut top = names(&v); + top.sort(); + assert_eq!( + top, + [ + "approximated", + "changed", + "conflictsCleared", + "failed", + "imported", + "matchedWithoutChange", + "set", + "skipped", + "suggestions", + "truncated", + "unmatched", + "unmatchedWithoutShortcut", + "unreadable" + ] + ); + let sorted = |v: &serde_json::Value| { + let mut n = names(v); + n.sort(); + n + }; + assert_eq!(sorted(&v["changed"][0]), ["command", "from", "item", "label", "line", "to"]); + assert_eq!(sorted(&v["conflictsCleared"][0]), ["command", "label", "shortcut"]); + assert_eq!(sorted(&v["skipped"][1]), ["command", "item", "keptBy", "line", "reason", "shortcut"]); + assert_eq!(v["skipped"][1]["keptBy"], json!({"id": "edit.clear", "params": null})); + assert_eq!(v["skipped"][0]["reason"], "reserved"); + assert_eq!(sorted(&v["unmatched"][0]), ["item", "keys", "line", "reason"]); + let reasons: Vec<&str> = v["unmatched"].as_array().unwrap().iter().filter_map(|u| u["reason"].as_str()).collect(); + assert_eq!(reasons, ["noMatch", "ambiguous", "context", "tool", "noMatch"]); + assert_eq!(sorted(&v["unreadable"][0]), ["item", "keys", "line", "reason"]); + assert_eq!(sorted(&v["suggestions"][0]), ["candidates", "item", "line"]); + let mut approximated = ImportReport::default(); + approximated.approximated = plan(&numpad_set(), &km, &options).report.approximated; + let a = serde_json::to_value(&approximated).unwrap(); + assert_eq!(sorted(&a["approximated"][0]), ["item", "keys", "line", "note"]); + assert_eq!(a["approximated"][0]["note"], "numpad"); + assert_eq!(v["changed"][0]["from"], json!(["Cmd+W"])); +} + +fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); + let report = plan(&ForeignSet::new(entries), &km, options).apply(&mut km); + (km, report) +} + +fn default_context() -> ImportOptions { + ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() } +} + +fn unmatched(r: &ImportReport) -> Vec<(&str, UnmatchedReason)> { + r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect() +} + +#[test] +fn panel_menu_entries_match_by_path_end_or_label() { + let catalog = vec![ + command("type.allCaps", "All Caps", &["Panels", "Character", "All Caps"], &[]), + command("pages.overrideAll", "Override All Parent Items", &["Pages", "Override All Parent Page Items"], &[]), + command("paragraph.justification", "Justification…", &[], &[]), + command("swatches.new", "New Swatch", &["Swatches", "New Swatch"], &[]), + command("gradient.new", "New Swatch", &["Gradient", "New Swatch"], &[]), + command("character.smallCaps", "Small Caps", &[], &[]).with_alias("Character: Small Caps"), + // The app has Paragraph and Color panels. + command(panel("paragraph"), "Paragraph", &["Window", "Paragraph"], &[]), + command(panel("color"), "Color", &["Window", "Color"], &[]), + ]; + let d = "Default"; + let panel_entry = |path: &[&str], key: &str| entry(None, path, &[(d, key)], Flavor::Windows).in_panel_menu(); + let entries = vec![ + // The end of a path, label last. + panel_entry(&["Character", "All Caps"], "Ctrl+F1"), + // The end of a path whose last segment differs from the label. + panel_entry(&["Pages", "Override All Parent Page Items"], "Ctrl+F2"), + // The last segment alone, as one command's label, for a panel the app has. + panel_entry(&["Paragraph", "Justification..."], "Ctrl+F3"), + // The last segment alone matches nothing for a panel the app lacks. + panel_entry(&["History", "Justification..."], "Ctrl+F8"), + // The panel picks one of two commands with the label. + panel_entry(&["Swatches", "New Swatch"], "Ctrl+F4"), + // The joined segments as an alias. + panel_entry(&["Character", "Small Caps"], "Ctrl+F5"), + // No path end fits and the label alone fits two commands. + panel_entry(&["Color", "New Swatch"], "Ctrl+F6"), + // Without the mark, a path with no menu matches only a whole catalog path. + entry(None, &["Character", "All Caps"], &[(d, "Ctrl+F7")], Flavor::Windows), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "type.allCaps", &["Cmd+F1"]); + has(&km, "pages.overrideAll", &["Cmd+F2"]); + has(&km, "paragraph.justification", &["Cmd+F3"]); + has(&km, "swatches.new", &["Cmd+F4"]); + has(&km, "character.smallCaps", &["Cmd+F5"]); + has(&km, "gradient.new", &[]); + assert_eq!( + unmatched(&r), + [ + ("History > Justification...", UnmatchedReason::NoMatch), + ("Color > New Swatch", UnmatchedReason::Ambiguous), + ("Character > All Caps", UnmatchedReason::NoMatch) + ] + ); +} + +#[test] +fn aliases_match_like_labels() { + let catalog = vec![ + command("app.quit", "Quit DesignCraft", &["File", "Quit DesignCraft"], &[]).with_alias("Exit"), + command("app.preferences", "Preferences…", &["Edit", "Preferences…"], &[]).with_alias("Preferences: General…"), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]).with_alias("Apply bold"), + command("edit.paste", "Paste", &["Edit", "Paste"], &[]).with_alias("Einfügen"), + ]; + let d = "Default"; + let entries = vec![ + win("File", &["Exit"], &[(d, "Ctrl+Q")]), + win("Edit", &["Preferences", "General..."], &[(d, "Ctrl+K")]), + entry(None, &["Apply Bold"], &[(d, "Ctrl+Shift+B")], Flavor::Windows), + win("Bearbeiten", &["Einfügen"], &[(d, "Ctrl+Shift+V")]), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "app.quit", &["Cmd+Q"]); + has(&km, "app.preferences", &["Cmd+K"]); + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "edit.paste", &["Cmd+Shift+V"]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + +#[test] +fn import_paths_match_like_menu_paths() { + let catalog = vec![ + command("object.color", "Apply Color", &["Object", "Apply Color"], &[]), + command("color.applyColor", "Apply Color", &[], &[]).with_import_path(vec!["Tools".into(), "Apply Color".into()]), + ]; + let d = "Default"; + let entries = vec![win("Tools", &["Apply Color"], &[(d, "Ctrl+,")])]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "color.applyColor", &["Cmd+,"]); + has(&km, "object.color", &[]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + +#[test] +fn show_and_hide_are_read_off_only_for_toggles() { + let catalog = vec![ + command(panel("conveyor"), "Conveyor", &["Window", "Conveyor"], &[]), + command("view.rulers", "Rulers", &["View", "Rulers"], &[]).toggle(), + command("view.frameEdges", "Show Frame Edges", &["View", "Extras", "Show Frame Edges"], &[]).toggle(), + command("view.hiddenCharacters", "Show Hidden Characters", &["Type", "Show Hidden Characters"], &[]), + ]; + let d = "Default"; + let entries = vec![ + // Not a toggle: the name must fit as written. + win("View", &["Extras", "Hide Conveyor"], &[(d, "Ctrl+F1")]), + // One toggle for both of the source's items. + win("View", &["Show Rulers"], &[(d, "Ctrl+R")]), + win("View", &["Hide Rulers"], &[(d, "Ctrl+Shift+R")]), + win("View", &["Extras", "Hide Frame Edges"], &[(d, "Ctrl+H")]), + // Exact names match any command. + win("Type", &["Show Hidden Characters"], &[(d, "Ctrl+Alt+I")]), + win("Type", &["Hide Hidden Characters"], &[(d, "Ctrl+Alt+H")]), + ]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, panel("conveyor"), &[]); + has(&km, "view.rulers", &["Cmd+R", "Cmd+Shift+R"]); + has(&km, "view.frameEdges", &["Cmd+H"]); + has(&km, "view.hiddenCharacters", &["Cmd+Alt+I"]); + assert_eq!( + unmatched(&r), + [("View > Extras > Hide Conveyor", UnmatchedReason::NoMatch), ("Type > Hide Hidden Characters", UnmatchedReason::NoMatch)] + ); +} + +#[test] +fn only_entries_with_keys_to_bind_count_toward_suggestions() { + let catalog = vec![command("edit.checkSpelling", "Check Spelling…", &["Edit", "Check Spelling…"], &[])]; + let d = "Default"; + // Twice the cap of unmatched entries without keys, or with keys only in a skipped context, + // before one with a key. + let mut entries: Vec = (0..MAX_SUGGESTIONS) + .flat_map(|i| [win("Edit", &[&format!("Nothing {i}")], &[]), win("Edit", &[&format!("Tables {i}")], &[("Table", "Ctrl+T")])]) + .collect(); + entries.push(win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K"), ("Table", "Ctrl+T")])); + let options = ImportOptions { + contexts: vec![(d.into(), ContextRule::Home), ("Table".into(), ContextRule::Skip)], + suggest: true, + ..ImportOptions::default() + }; + let (_, r) = import_into(catalog, entries, &options); + assert_eq!(r.unmatched_without_shortcut, 2 * MAX_SUGGESTIONS); + assert_eq!(r.unmatched.len(), 1); + assert_eq!(r.unmatched[0].keys, "Default: Ctrl+K", "keys in a skipped context are left out"); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].candidates, [CommandRef::new("edit.checkSpelling")]); + assert_eq!(r.truncated, 0); +} + +fn pages_catalog() -> Vec { + vec![ + command("app.newDocumentDialog", "New Document…", &["File", "New Document…"], &["Cmd+N"]), + command("layout.pages.insert", "Insert Pages…", &["Layout", "Pages", "Insert Pages…"], &["Cmd+Shift+P"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + ] +} + +#[test] +fn panel_menu_entries_without_keys_change_nothing() { + let none = |path: &[&str]| ForeignEntry::new(None, path.iter().copied()).in_panel_menu(); + let entries = vec![ + // The app has no History panel, so the last segment alone does not match. + none(&["History", "New Document"]), + // A path end matches, but without keys the command keeps its shortcuts. + none(&["Pages", "Insert Pages..."]), + // The same for the last segment alone, with a panel the app has. + none(&["Pages", "New Document"]), + ]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "app.newDocumentDialog", &["Cmd+N"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert!(r.changed.is_empty(), "{:?}", r.changed); + assert_eq!(r.unmatched_without_shortcut, 1); + let rows: Vec<(&str, &str, NoChangeReason)> = + r.matched_without_change.iter().map(|m| (m.item.as_str(), m.command.id.as_str(), m.reason)).collect(); + assert_eq!( + rows, + [ + ("Pages > Insert Pages...", "layout.pages.insert", NoChangeReason::PanelMenu), + ("Pages > New Document", "app.newDocumentDialog", NoChangeReason::PanelMenu) + ] + ); + let v = serde_json::to_value(&r).unwrap(); + let mut fields: Vec = v["matchedWithoutChange"][0].as_object().unwrap().keys().cloned().collect(); + fields.sort(); + assert_eq!(fields, ["command", "item", "label", "line", "reason"]); + assert_eq!(v["matchedWithoutChange"][0]["reason"], "panelMenu"); +} + +#[test] +fn an_entry_without_keys_never_clears_keys_another_entry_gives() { + let d = "Default"; + let keyed = || win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Alt+P")]); + let keyless = || win("Layout", &["Insert Pages..."], &[]); + for entries in [vec![keyed(), keyless()], vec![keyless(), keyed()]] { + let (km, _) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Alt+P"]); + } + + // A panel-menu entry without keys, and the menu entry with the command's keys. + let entries = vec![ + ForeignEntry::new(None, ["Pages", "Insert Pages..."]).in_panel_menu(), + win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Shift+P")]), + ]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.matched_without_change.len(), 1); + + // The later entry still takes the chord from another command; the entry without keys does + // not clear the command that lost it. + let entries = vec![keyless(), keyed(), win("File", &["Close"], &[(d, "Ctrl+Alt+P")])]; + let (km, r) = import_into(pages_catalog(), entries, &default_context()); + has(&km, "file.close", &["Cmd+Alt+P"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.skipped.iter().map(|s| (s.command.id.as_str(), s.reason)).collect::>(), [("layout.pages.insert", SkipReason::Taken)]); +} diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs new file mode 100644 index 00000000..58a904b0 --- /dev/null +++ b/crates/keymap/tests/keymap.rs @@ -0,0 +1,665 @@ +use std::time::{Duration, Instant}; + +use craft_keymap::{ + Active, AssignError, Bindable, Binding, Chord, CommandRef, ConflictKind, DropReason, Key, Keymap, Kind, Lookup, MAX_BINDINGS, MAX_COMMANDS, + MAX_CONFLICTS, Mods, Overrides, Platform, Policy, Problem, Scope, Scopes, Trigger, TypingRule, reserved, +}; +use serde_json::json; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn scope(name: &str) -> Scope { + Scope::new(name).unwrap() +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn cmd(id: &str) -> CommandRef { + CommandRef::new(id) +} + +fn app(id: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(b(k))) +} + +fn in_scope(id: &str, home: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, scope(home)), |e, k| e.with_default(b(k))) +} + +fn run(id: &str) -> Lookup { + Lookup::Run(cmd(id)) +} + +fn scopes() -> Scopes { + Scopes::new().with_panel("Timeline").with_panel("Composition").with_share("Timeline", "Composition").with_context("Text") +} + +fn press(km: &Keymap, text: &str, active: &Active) -> Lookup { + km.lookup(&chord(text), false, active) +} + +#[test] +fn command_keys_are_canonical_whatever_the_map_order() { + let c = CommandRef::with_params("window.panel", json!({"z": [1, "a b"], "panel": "layers"})); + assert_eq!(c.key(), r#"window.panel {"panel":"layers","z":[1,"a b"]}"#); + assert_eq!(CommandRef::parse_key(&c.key()), Some(c)); + assert_eq!(CommandRef::with_params("edit.copy", json!({})).key(), "edit.copy"); + assert_eq!(CommandRef::parse_key("x {bad"), None); + assert_eq!(CommandRef::parse_key(&"x".repeat(300)), None); +} + +#[test] +fn lookup_picks_the_nearest_scope() { + let catalog = vec![ + app("edit.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + app("file.bake", &["Cmd+B"]), + in_scope("text.bold", "Text", &["Cmd+B", "Cmd+I"]), + in_scope("timeline.loop", "Timeline", &["L"]), + ]; + let km = Keymap::new(catalog.clone(), scopes(), Platform::Mac); + let text = ["Text"]; + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Timeline")), run("timeline.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Composition")), run("timeline.copy"), "sharing panel"); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Effects")), run("edit.copy")); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Timeline")), run("file.bake")); + assert_eq!(press(&km, "Cmd+B", &Active { panel: Some("Timeline"), contexts: &text, typing: false }), run("text.bold")); + assert_eq!(press(&km, "Cmd+I", &Active::NONE), Lookup::None, "inactive context"); + assert_eq!(press(&km, "L", &Active::panel("Effects")), run("timeline.loop"), "fallthrough"); + assert_eq!(km.lookup(&chord("L"), true, &Active::panel("Timeline")), Lookup::None, "double press is its own trigger"); + + let strict = Keymap::new(catalog, scopes().with_panel_fallthrough(false), Platform::Mac); + assert_eq!(press(&strict, "L", &Active::panel("Effects")), Lookup::None); + assert_eq!(press(&strict, "L", &Active::panel("Timeline")), run("timeline.loop")); +} + +#[test] +fn typing_rules_decide_what_fires_in_a_text_field() { + let catalog = vec![app("tool.brush", &["B"]), app("edit.copy", &["Cmd+C"]), app("help", &["F1"]), app("view.alt", &["Alt+V"])]; + let typing = Active { panel: None, contexts: &[], typing: true }; + let cases = [ + (TypingRule::PlainKeysWait, [false, true, true, true]), + (TypingRule::CommandKeysOnly, [false, true, true, false]), + (TypingRule::AllWait, [false, false, false, false]), + ]; + for (rule, fires) in cases { + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_typing_rule(rule); + for ((key, id), fire) in [("B", "tool.brush"), ("Cmd+C", "edit.copy"), ("F1", "help"), ("Alt+V", "view.alt")].into_iter().zip(fires) { + let want = if fire { run(id) } else { Lookup::None }; + assert_eq!(press(&km, key, &typing), want, "{rule:?} {key}"); + assert_eq!(press(&km, key, &Active::NONE), run(id), "{rule:?} {key} not typing"); + } + } +} + +#[test] +fn text_editing_chords_go_to_the_text_field_under_every_rule() { + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("file.save", &["Cmd+S"]), app("nav.word", &["Alt+Left"]), app("nav.start", &["Cmd+Home"])]; + let typing = Active { typing: true, ..Active::NONE }; + for rule in [TypingRule::PlainKeysWait, TypingRule::CommandKeysOnly] { + for platform in [Platform::Mac, Platform::Windows] { + let km = Keymap::new(catalog.clone(), Scopes::new(), platform).with_typing_rule(rule); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None, "{rule:?} {platform:?}"); + assert_eq!(press(&km, "Cmd+Z", &Active::NONE), run("edit.undo")); + assert_eq!(press(&km, "Cmd+S", &typing), run("file.save"), "{rule:?} {platform:?}"); + } + } + // Word and line keys follow the platform. + let mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac); + let windows = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + assert_eq!(press(&mac, "Alt+Left", &typing), Lookup::None); + assert_eq!(press(&windows, "Alt+Left", &typing), run("nav.word")); + assert_eq!(press(&windows, "Ctrl+Home", &typing), Lookup::None); + assert_eq!(press(&mac, "Cmd+Home", &typing), run("nav.start")); + + // A binding in an active context takes the chord from the field. + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("text.undo", &["Text: Cmd+Z"])]; + let km = Keymap::new(catalog, scopes(), Platform::Windows); + let editing = Active { contexts: &["Text"], typing: true, ..Active::NONE }; + assert_eq!(press(&km, "Cmd+Z", &editing), run("text.undo")); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None); + assert!(!TypingRule::PlainKeysWait.fires(&chord("Ctrl+Z"), Platform::Linux)); +} + +#[test] +fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { + let catalog = vec![ + app("text.bold", &["Text: Cmd+B", "Text: B"]), + app("file.bake", &["Cmd+B"]), + in_scope("timeline.cut", "Timeline", &["Cmd+K"]), + app("file.save", &["Cmd+S"]), + ]; + let editing = Active { panel: Some("Timeline"), contexts: &["Text"], typing: true }; + let elsewhere = Active { contexts: &[], ..editing }; + let km = Keymap::new(catalog, scopes(), Platform::Mac).with_typing_rule(TypingRule::AllWait); + assert_eq!(press(&km, "Cmd+B", &editing), run("text.bold")); + assert_eq!(press(&km, "B", &editing), run("text.bold")); + assert_eq!(press(&km, "Cmd+K", &editing), Lookup::None, "panel bindings wait"); + assert_eq!(press(&km, "Cmd+S", &editing), Lookup::None, "app bindings wait"); + assert_eq!(press(&km, "Cmd+B", &elsewhere), Lookup::None, "the app binding waits when the context is not active"); +} + +#[test] +fn tools_of_one_group_cycle_and_keep_their_shared_key() { + let tool = |id: &str, group: &str| app(id, &["B"]).with_kind(Kind::Tool { group: Some(group.into()) }); + let mut km = + Keymap::new(vec![tool("tool.brush", "brush"), tool("tool.pencil", "brush"), tool("tool.mixer", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&km, "B", &Active::NONE), Lookup::Cycle(vec![cmd("tool.brush"), cmd("tool.pencil"), cmd("tool.mixer")])); + assert!(km.conflicts().is_empty()); + + km.clear(&cmd("tool.mixer")); + let done = km.assign(&cmd("tool.mixer"), b("B"), Policy::Steal).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("tool.brush")), vec![b("B")]); + + let mixed = Keymap::new(vec![tool("tool.brush", "brush"), app("layer.bake", &["B"]), tool("tool.pencil", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&mixed, "B", &Active::NONE), run("tool.brush")); +} + +#[test] +fn steal_keep_and_refuse() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), in_scope("timeline.copy", "Timeline", &[])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let dup = cmd("edit.duplicate"); + + let err = km.assign(&dup, b("Cmd+C"), Policy::Refuse).unwrap_err(); + let AssignError::Taken(holders) = err else { panic!("{err:?}") }; + assert_eq!(holders[0].command, cmd("edit.copy")); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D")]); + + let done = km.assign(&dup, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(done.clashes.len(), 1); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D"), b("Cmd+C")]); + + km.reset_all(); + let done = km.assign(&dup, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(done.taken_from[0].command, cmd("edit.copy")); + assert!(km.bindings(&cmd("edit.copy")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&[][..])); + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.duplicate")); + + // A panel binding shadows the application one; nothing is taken. + let done = km.assign(&cmd("timeline.copy"), b("Cmd+D"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty() && done.clashes.is_empty()); + assert_eq!(km.assign(&cmd("nope"), b("Cmd+D"), Policy::Steal), Err(AssignError::UnknownCommand)); +} + +#[test] +fn twins_keep_their_shared_default_key() { + let mut km = Keymap::new(vec![app("file.close", &["Cmd+W"]), app("sequence.close", &["Cmd+W"])], Scopes::new(), Platform::Mac); + km.clear(&cmd("sequence.close")); + let done = km.assign(&cmd("sequence.close"), b("Cmd+W"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("file.close")), vec![b("Cmd+W")]); + assert!(km.overrides().is_empty(), "back to the defaults"); +} + +#[test] +fn clearing_a_command_frees_its_panel_copies() { + // filmcraft#1333: New Bin holds Cmd+B in the application and in the Project panel. + let catalog = vec![app("project.newBin", &["Cmd+B", "Project: Cmd+B"]), app("clip.bold", &[])]; + let mut km = Keymap::new(catalog, Scopes::new().with_panel("Project"), Platform::Mac); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), run("project.newBin")); + assert!(km.clear(&cmd("project.newBin"))); + assert!(km.bindings(&cmd("project.newBin")).is_empty()); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), Lookup::None); + assert!(km.assign(&cmd("clip.bold"), b("Project: Cmd+B"), Policy::Refuse).is_ok()); +} + +#[test] +fn reserved_chords_are_refused() { + let catalog = vec![app("view.next", &[])]; + let mut mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_reserved(reserved::system(Platform::Mac)); + let err = mac.assign(&cmd("view.next"), b("Cmd+Tab"), Policy::Steal).unwrap_err(); + assert_eq!(err, AssignError::Reserved(Trigger::parse("Cmd+Tab").unwrap())); + + let mut win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + assert!(matches!(win.assign(&cmd("view.next"), b("Ctrl+Alt+Delete"), Policy::Steal), Err(AssignError::Reserved(_)))); + assert!(reserved::system(Platform::Linux).contains(&chord("Cmd+Alt+F12"))); + + let mut o = Overrides::new(); + o.insert("view.next", vec!["Alt+F4".into()]); + let linux = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert!(linux.bindings(&cmd("view.next")).is_empty()); + assert_eq!(linux.problems(), vec![Problem::Reserved { key: "view.next".into(), text: "Alt+F4".into() }]); +} + +#[test] +fn conflicts_tell_clashes_from_shadows() { + let catalog = vec![ + app("edit.copy", &["Cmd+C"]), + app("color.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + in_scope("text.cut", "Text", &["Cmd+X"]), + app("edit.cut", &["Cmd+X"]), + app("edit.find", &["Cmd+F"]), + in_scope("tab.find", "Effects", &["Cmd+F"]), + in_scope("render.find", "Render", &["Cmd+R"]), + in_scope("effects.render", "Effects", &["Cmd+R"]), + ]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let summary = |km: &Keymap| -> Vec<(String, String, String, ConflictKind, bool)> { + km.conflicts().into_iter().map(|c| (c.chord.to_string(), c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect() + }; + let s = |a: &str, x: &str, y: &str, k, d| (a.to_string(), x.to_string(), y.to_string(), k, d); + let mut want = vec![ + s("Cmd+C", "edit.copy", "color.copy", ConflictKind::Clash, true), + s("Cmd+C", "edit.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+C", "color.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+X", "text.cut", "edit.cut", ConflictKind::Shadow, true), + s("Cmd+F", "edit.find", "tab.find", ConflictKind::Shadow, true), + // Fallthrough: in a third panel the two tie. + s("Cmd+R", "render.find", "effects.render", ConflictKind::Shadow, true), + ]; + let sort = |mut v: Vec<(String, String, String, ConflictKind, bool)>| { + v.sort(); + v + }; + assert_eq!(sort(summary(&km)), sort(want.clone())); + + km.assign(&cmd("edit.find"), b("Cmd+D"), Policy::Steal).unwrap(); + km.assign(&cmd("edit.cut"), b("Cmd+D"), Policy::Keep).unwrap(); + want.push(s("Cmd+D", "edit.cut", "edit.find", ConflictKind::Clash, false)); + assert_eq!(sort(summary(&km)), sort(want.clone())); + + let order: Vec<_> = summary(&km).into_iter().map(|(c, a, b, ..)| (c, a, b)).collect(); + let chords: Vec = km.conflicts().iter().map(|c| c.chord).collect(); + assert!(chords.is_sorted(), "{order:?}"); + let c = |x: &str, y: &str| order.iter().position(|(_, a, b)| a == x && b == y); + assert!(c("edit.copy", "color.copy") < c("edit.copy", "timeline.copy"), "{order:?}"); + assert!(c("edit.copy", "timeline.copy") < c("color.copy", "timeline.copy"), "{order:?}"); + + // One command's conflicts, with that command as `a`. + let with = |id: &str| -> Vec<(String, String, ConflictKind)> { + km.conflicts_with(&cmd(id)).into_iter().map(|c| (c.chord.to_string(), c.b.0.id, c.kind)).collect() + }; + let w = |chord: &str, other: &str, kind| (chord.to_string(), other.to_string(), kind); + assert_eq!(with("edit.copy"), [w("Cmd+C", "color.copy", ConflictKind::Clash), w("Cmd+C", "timeline.copy", ConflictKind::Shadow)]); + assert_eq!(with("edit.cut"), [w("Cmd+X", "text.cut", ConflictKind::Shadow), w("Cmd+D", "edit.find", ConflictKind::Clash)]); + assert!(with("nope").is_empty()); + for entry in km.catalog() { + let id = entry.command.id.as_str(); + let mut direct: Vec<_> = + km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); + let mut filtered: Vec<_> = km + .conflicts() + .into_iter() + .filter(|c| c.a.0.id == id || c.b.0.id == id) + .map(|c| { + if c.a.0.id == id { + (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults) + } else { + (c.chord, c.b.0.id, c.a.0.id, c.kind, c.in_defaults) + } + }) + .collect(); + direct.sort(); + filtered.sort(); + assert_eq!(direct, filtered, "{id}"); + } + + let strict = Keymap::new(km.catalog().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); + assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); +} + +/// `commands` commands, each with 16 bindings on one chord in 16 panels. +fn crowded(commands: usize, scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { + let catalog: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); + let mut o = Overrides::new(); + for n in 0..commands { + assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); + } + (Keymap::new(catalog, scopes, Platform::Mac), o) +} + +fn panels(n: usize) -> Scopes { + (1..=n).fold(Scopes::new(), |s, p| s.with_panel(&format!("P{p}"))) +} + +#[test] +fn crowded_chords_are_capped() { + // Panels the app never registered are not bound. + let (km, o) = crowded(2, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + assert!(km.conflicts().is_empty()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: 32 commands make 16 × 496 pairs, capped. + let (km, o) = crowded(32, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + assert_eq!(km.with_overrides(o).conflicts().len(), MAX_CONFLICTS); + + // Tools of one group: no conflicts. + let (km, o) = crowded(32, panels(MAX_BINDINGS), Kind::Tool { group: None }); + assert!(km.with_overrides(o).conflicts().is_empty()); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn crowded_chords_stay_fast() { + let limit = Duration::from_secs(8); + + // Panels the app never registered are not bound. + let (km, o) = crowded(MAX_COMMANDS, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: many conflicts, capped. + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert_eq!(km.conflicts().len(), MAX_CONFLICTS); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + + // Tools of one group: no conflicts, and no pair is compared. + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS), Kind::Tool { group: None }); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); +} + +#[test] +fn assigning_many_commands_stays_fast() { + let catalog: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[])).collect(); + let mut km = Keymap::new(catalog, panels(MAX_BINDINGS), Platform::Mac); + let list: Vec = (1..=MAX_BINDINGS).map(|p| b(&format!("P{p}: K"))).collect(); + let t = Instant::now(); + for n in 0..MAX_COMMANDS { + let done = km.set_bindings(&cmd(&format!("c.{n}")), list.clone(), Policy::Steal).unwrap(); + assert_eq!(done.taken_from.len(), if n == 0 { 0 } else { MAX_BINDINGS }); + } + assert!(t.elapsed() < Duration::from_secs(8), "{:?}", t.elapsed()); + assert_eq!(km.bindings(&cmd(&format!("c.{}", MAX_COMMANDS - 1))), list); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(press(&km, "K", &Active::panel("P3")), run(&format!("c.{}", MAX_COMMANDS - 1))); +} + +#[test] +fn binding_text_round_trips_for_every_key() { + let panel = scope("Timeline"); + for &key in Key::ALL { + for mods in [Mods::NONE, Mods { cmd: true, ctrl: true, alt: true, shift: true }] { + let chord = Chord::new(mods, key); + for trigger in [Trigger::single(chord), Trigger::double(chord)] { + for binding in [Binding::new(trigger), Binding::in_scope(trigger, panel.clone())] { + let text = binding.to_string(); + assert_eq!(Binding::parse(&text).as_ref(), Ok(&binding), "{text}"); + } + } + } + } + assert_eq!(Binding::parse(": :"), Ok(Binding::new(Trigger::double(Chord::plain(Key::Colon))))); +} + +#[test] +fn scope_names_with_outer_whitespace_are_refused() { + for name in [" Timeline", "Timeline ", "\u{a0}Timeline", ""] { + assert_eq!(Scope::new(name), None, "{name:?}"); + } + assert_eq!(Scope::new("Time line").map(|s| s.to_string()).as_deref(), Some("Time line")); + assert_eq!(Binding::parse(" Timeline : K").map(|b| b.scope), Ok(Some(scope("Timeline")))); +} + +#[test] +fn edits_are_refused_when_the_overrides_are_full() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; + let mut o = Overrides::new(); + o.insert("edit.cut", vec!["Cmd+X".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec![]); + } + assert_eq!(o.len(), MAX_COMMANDS); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o.clone()); + let (copy, dup, cut) = (cmd("edit.copy"), cmd("edit.duplicate"), cmd("edit.cut")); + + assert_eq!(km.assign(&dup, b("Cmd+K"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.set_bindings(&dup, vec![], Policy::Steal), Err(AssignError::Full)); + assert!(!km.clear(©)); + assert!(!km.remove(©, &b("Cmd+C"))); + // Taking Cmd+C from edit.copy would need an entry for it too. + assert_eq!(km.assign(&cut, b("Cmd+C"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.bindings(©), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&cut), vec![b("Cmd+X")]); + assert_eq!(km.overrides(), &o); + + // Commands that have an entry still change, and back to the defaults frees one. + assert!(km.assign(&cut, b("Cmd+Shift+X"), Policy::Steal).is_ok()); + assert!(km.reset(&cut)); + assert!(km.assign(&dup, b("Cmd+K"), Policy::Steal).is_ok()); +} + +#[test] +fn bindings_in_unknown_scopes_are_refused() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac); + let copy = cmd("edit.copy"); + assert_eq!(km.assign(©, b("Nowhere: Cmd+K"), Policy::Steal), Err(AssignError::UnknownScope(scope("Nowhere")))); + assert!(km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).is_ok(), "a catalog scope"); + assert!(km.assign(©, b("Text: Cmd+K"), Policy::Steal).is_ok(), "a registered scope"); + + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into(), "Cmd+J".into()]); + km.set_overrides(o); + assert_eq!(km.bindings(©), vec![b("Cmd+J")]); + assert_eq!(km.problems(), vec![Problem::UnknownScope { key: "edit.copy".into(), text: "Nowhere: Cmd+K".into() }]); +} + +#[test] +fn unreadable_entries_keep_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+Nope".into()]); + o.insert("edit.paste", vec![]); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string()][..]), "verbatim"); + assert!(km.reset(&cmd("edit.copy"))); + assert_eq!(km.overrides().get("edit.copy"), None); +} + +#[test] +fn entries_with_nothing_usable_keep_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.cut", &["Cmd+X"]), app("edit.paste", &["Cmd+V"]), app("edit.undo", &["Cmd+Z"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into()]); + o.insert("edit.cut", vec!["Alt+F4".into()]); + o.insert("edit.paste", vec!["Nowhere: Cmd+K".into(), "Alt+F4".into(), "Cmd+J".into()]); + o.insert("edit.undo", vec![]); + let km = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")], "only an unknown scope"); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+X")], "only a reserved chord"); + assert_eq!(km.bindings(&cmd("edit.paste")), vec![b("Cmd+J")]); + assert!(km.bindings(&cmd("edit.undo")).is_empty()); +} + +#[test] +fn overrides_hold_only_what_differs_from_the_defaults() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); + let copy = cmd("edit.copy"); + km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).unwrap(); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+C".to_string(), "Timeline: Cmd+K".to_string()][..])); + assert_eq!(km.bindings(©)[1], Binding::in_scope(Trigger::parse("Cmd+K").unwrap(), scope("Timeline"))); + assert!(km.remove(©, &b("Timeline: Cmd+K"))); + assert!(km.overrides().is_empty()); + + km.set_bindings(&cmd("timeline.mark"), vec![b("Timeline: M")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "the home scope written out is the same binding"); + km.set_bindings(©, vec![b("Cmd+Shift+C")], Policy::Steal).unwrap(); + assert!(km.reset(©)); + assert!(km.overrides().is_empty()); +} + +#[test] +fn edits_keep_the_texts_this_build_cannot_use() { + let catalog = vec![app("file.quit", &["Cmd+Q"]), app("file.close", &["Cmd+W"]), app("file.save", &["Cmd+S"])]; + let mut o = Overrides::new(); + o.insert("file.quit", vec!["Cmd+Q".into(), "Cmd+FutureKey".into(), "Nowhere: Cmd+J".into()]); + let mut km = Keymap::new(catalog.clone(), scopes(), Platform::Mac).with_overrides(o); + let quit = cmd("file.quit"); + let texts = |km: &Keymap| km.overrides().get("file.quit").map(<[String]>::to_vec); + let strings = |t: &[&str]| Some(t.iter().map(|s| s.to_string()).collect::>()); + + km.assign(&quit, b("Cmd+W"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+Q", "Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"])); + km.assign(&cmd("file.save"), b("Cmd+Q"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"]), "taken by a Steal"); + let reloaded = Keymap::new(catalog, scopes(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(reloaded.bindings(&quit), vec![b("Cmd+W")]); + + // An entry of only such texts would load as the defaults, so a cleared command drops them. + assert!(km.clear(&quit)); + assert_eq!(texts(&km), strings(&[])); + assert!(km.reset(&quit)); + assert_eq!(texts(&km), None); +} + +#[test] +fn an_override_equal_to_the_defaults_on_this_platform_is_not_stored() { + let mut km = Keymap::new(vec![app("file.save", &["Cmd+S"])], Scopes::new(), Platform::Windows); + km.set_bindings(&cmd("file.save"), vec![b("Ctrl+S")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty()); + assert_eq!(press(&km, "Ctrl+S", &Active::NONE), run("file.save")); +} + +#[test] +fn an_empty_text_in_the_map_form_removes_the_shortcut() { + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"]), app("edit.cut", &["Cmd+X"])]; + let o: Overrides = serde_json::from_str(r#"{"edit.copy": "", "edit.paste": [""], "edit.cut": "Cmd+K"}"#).unwrap(); + assert!(o.load_notes().is_empty(), "{:?}", o.load_notes()); + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o); + assert!(km.bindings(&cmd("edit.copy")).is_empty(), "a removed default stays removed"); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+K")]); + assert!(km.problems().is_empty()); + + let saved = serde_json::to_string(km.overrides()).unwrap(); + let again = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(serde_json::from_str(&saved).unwrap()); + assert!(again.bindings(&cmd("edit.copy")).is_empty(), "{saved}"); +} + +#[test] +fn overrides_write_the_map_form_unless_a_command_has_several_keys() { + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+K".into()]); + o.insert("edit.paste", vec![]); + let text = serde_json::to_string(&o).unwrap(); + assert_eq!(text, r#"{"edit.copy":"Cmd+K","edit.paste":""}"#, "older builds read one text per command"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + o.insert("edit.cut", vec!["Cmd+X".into(), "Shift+Delete".into()]); + let text = serde_json::to_string(&o).unwrap(); + assert!(text.starts_with(r#"[{"command":"edit.copy","keys":["Cmd+K"]},"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + // A blank text is kept verbatim (it loads as the defaults), which the map form cannot say. + let mut blank = Overrides::new(); + blank.insert("edit.copy", vec![" ".into()]); + let text = serde_json::to_string(&blank).unwrap(); + assert!(text.starts_with('['), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), blank); + + #[derive(serde::Serialize)] + struct Prefs { + #[serde(with = "craft_keymap::keymap::overrides_list")] + shortcuts: Overrides, + } + let mut single = Overrides::new(); + single.insert("edit.copy", vec!["Cmd+K".into()]); + let text = serde_json::to_string(&Prefs { shortcuts: single }).unwrap(); + assert_eq!(text, r#"{"shortcuts":[{"command":"edit.copy","keys":["Cmd+K"]}]}"#); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let catalog = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; + let win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + let mac = Keymap::new(catalog, Scopes::new(), Platform::Mac); + let conflicts = win.conflicts(); + assert_eq!(conflicts.len(), 1); + assert_eq!((conflicts[0].kind, conflicts[0].chord.to_string()), (ConflictKind::Clash, "Cmd+X".to_string())); + assert!(mac.conflicts().is_empty()); + assert_eq!(press(&win, "Ctrl+X", &Active::NONE), run("edit.cut")); + assert_eq!(press(&mac, "Ctrl+X", &Active::NONE), run("mac.cut")); +} + +#[test] +fn hostile_overrides_load_without_panic_and_report_problems() { + let mut doc = serde_json::Map::new(); + for i in 0..(MAX_COMMANDS + 100) { + doc.insert(format!("junk.{i}"), json!(["Cmd+Z"])); + } + doc.insert("edit.copy".into(), json!(["Cmd+Nope", "x".repeat(10_000), 7, "Shift+C"])); + doc.insert("a".into(), json!(["Shift+A"])); + doc.insert("é".repeat(300), json!([])); + doc.insert("b".into(), json!({"deep": [[[[1]]]]})); + let o: Overrides = serde_json::from_value(serde_json::Value::Object(doc)).unwrap(); + assert!(o.len() <= MAX_COMMANDS); + + let km = Keymap::new(vec![app("edit.copy", &["Cmd+C"]), app("a", &[])], Scopes::new(), Platform::Linux).with_overrides(o.clone()); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Shift+C")]); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string(), "Shift+C".to_string()][..]), "verbatim"); + let problems = km.problems(); + assert!(problems.iter().any(|p| matches!(p, Problem::TooManyCommands { .. }))); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::TextTooLong })); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::NotText })); + assert!(problems.iter().any(|p| matches!(p, Problem::Dropped { reason: DropReason::KeyTooLong, .. }))); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope"))); + assert!(problems.iter().any(|p| matches!(p, Problem::UnknownCommand { key } if key == "junk.0"))); + + let text = serde_json::to_string(&o).unwrap(); + let again: Overrides = serde_json::from_str(&text).unwrap(); + assert_eq!(again, o); + assert!(text.starts_with(r#"[{"command":"a","keys":["Shift+A"]},"#), "{}", &text[..80]); + assert!(serde_json::from_str::(r#"{"x": {"y": 1}, "z": true, "w": null, "v": "Cmd+V"}"#).is_ok()); + let items: Overrides = + serde_json::from_str(r#"[{"command": "v", "keys": "Cmd+V"}, {"command": "x"}, {"keys": []}, 3, [], {"command": "y", "keys": [1]}]"#).unwrap(); + assert_eq!(items.iter().collect::>(), [("v", &["Cmd+V".to_string()][..]), ("y", &[][..])]); + assert_eq!(items.load_notes().len(), 5); + + let twice: Overrides = serde_json::from_str(r#"[{"command": "v", "keys": ["Cmd+V"]}, {"command": "v", "keys": []}]"#).unwrap(); + assert_eq!(twice.get("v"), Some(&["Cmd+V".to_string()][..]), "the first entry stays"); + assert_eq!(twice.load_notes(), [Problem::DuplicateKey { key: "v".into() }]); + for text in [r#""x""#, "5", "true", "null"] { + let o: Overrides = serde_json::from_str(text).unwrap(); + assert!(o.is_empty(), "{text}"); + assert_eq!(o.load_notes(), [Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{text}"); + } +} + +#[test] +fn reset_removes_hand_written_variants_of_a_key() { + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + let catalog = vec![Bindable::new(layers.clone(), "Layers", Scope::app()).with_default(b("F7"))]; + let load = || { + let mut o = Overrides::new(); + o.insert(r#"window.panel {"panel":"layers"}"#, vec!["Cmd+L".into()]); + o.insert(r#"window.panel { "panel": "layers" }"#, vec!["Cmd+K".into()]); + Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o) + }; + let mut km = load(); + assert_eq!(km.bindings(&layers), vec![b("Cmd+L")], "the canonical entry wins"); + km.reset(&layers); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + + // Setting the defaults drops the entries too. + let mut km = load(); + km.set_bindings(&layers, vec![b("F7")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + let again = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(again.bindings(&layers), vec![b("F7")]); +} diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs new file mode 100644 index 00000000..46ff8876 --- /dev/null +++ b/crates/keymap/tests/layout.rs @@ -0,0 +1,226 @@ +use craft_keymap::{Chord, Key, KeyInput, Layout, Mods, Platform}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +/// A press as egui reports it at the US position `physical`: the modifiers held and the logical +/// key, which is the physical key when the character typed has no key (`ф`, `é`, `ข`). +fn at(reported: &str, physical: Key) -> KeyInput { + let c = chord(reported); + KeyInput::new(c.mods).with_typed(c.key).with_physical(physical) +} + +fn texts(chords: &[Chord]) -> Vec { + chords.iter().map(Chord::to_string).collect() +} + +#[test] +fn each_layout_resolves_presses_by_the_rule() { + let latin = Layout::new(); + let other = Layout::new().with_latin(false); + let rows: &[(&str, &Layout, KeyInput, &str)] = &[ + // Russian: letters type Cyrillic, which egui reports as the physical key. + ("Russian ⌘С", &other, at("Cmd+C", Key::C), "Cmd+C"), + ("Russian ⌘Х", &other, at("Cmd+[", Key::OpenBracket), "Cmd+["), + ("Russian / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + ("Russian ⇧7 types ?", &other, at("Shift+?", Key::Num7), "?"), + // Arabic 101: ⇧D types ]. + ("Arabic ⌃⇧D", &other, at("Cmd+Shift+]", Key::D), "Cmd+Shift+D"), + ("Arabic ⌃V", &other, at("Cmd+V", Key::V), "Cmd+V"), + // Hebrew: Q types /, W types ', the / position types .. + ("Hebrew ⌃Q", &other, at("Cmd+/", Key::Q), "Cmd+Q"), + ("Hebrew ⌃W", &other, at("Cmd+'", Key::W), "Cmd+W"), + ("Hebrew / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + // Greek: Q types ;. + ("Greek ⌃Q", &other, at("Cmd+;", Key::Q), "Cmd+Q"), + ("Greek ⌃Z", &other, at("Cmd+Z", Key::Z), "Cmd+Z"), + // Dvorak + Russian, as the suite's egui-winit reports them: the Latin layout's character + // for each key. Dvorak types ' on the Q position, which egui reports as Q or as '. + ("Dvorak + Russian ⌃С", &other, at("Cmd+J", Key::C).with_latin('j'), "Cmd+J"), + ("Dvorak + Russian ⌃Й", &other, at("Cmd+Q", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian ⌃Й as '", &other, at("Cmd+'", Key::Q).with_latin('\''), "Cmd+'"), + ("Dvorak + Russian / key types .", &other, at("Cmd+.", Key::Slash).with_latin('z'), "Cmd+Z"), + // Dvorak + Hebrew: the Q position types / and has no Dvorak letter. + ("Dvorak + Hebrew ⌃Q", &other, at("Cmd+/", Key::Q).with_latin('\''), "Cmd+/"), + // Thai Kedmanee: 2 types /, - types ข, D types ก. + ("Thai 2 key types /", &other, at("Cmd+/", Key::Num2), "Cmd+/"), + ("Thai - key types ข", &other, at("Cmd+-", Key::Minus), "Cmd+-"), + ("Thai ⌃D", &other, at("Cmd+D", Key::D), "Cmd+D"), + // AZERTY. + ("AZERTY A", &latin, at("Cmd+A", Key::Q), "Cmd+A"), + ("AZERTY M", &latin, at("Cmd+M", Key::Semicolon), "Cmd+M"), + ("AZERTY M key types ,", &latin, at("Cmd+,", Key::M), "Cmd+,"), + ("AZERTY & key", &latin, at("Cmd+1", Key::Num1), "Cmd+1"), + ("AZERTY ⇧& types 1", &latin, at("Cmd+Shift+1", Key::Num1), "Cmd+Shift+1"), + ("AZERTY 6 key types -", &latin, at("Cmd+-", Key::Num6), "Cmd+-"), + ("AZERTY ⇧; types .", &latin, at("Shift+.", Key::Comma), "."), + // Dvorak. + ("Dvorak O", &latin, at("Cmd+O", Key::S), "Cmd+O"), + ("Dvorak ⇧O keeps Shift", &latin, at("Cmd+Shift+O", Key::S), "Cmd+Shift+O"), + ("Dvorak Q key types '", &latin, at("Cmd+'", Key::Q), "Cmd+'"), + ("Dvorak W key types ,", &latin, at("Cmd+,", Key::W), "Cmd+,"), + // QWERTZ (German). + ("QWERTZ ⇧⌘7 types /", &latin, at("Cmd+Shift+/", Key::Num7), "Cmd+/"), + ("QWERTZ Z", &latin, at("Cmd+Z", Key::Y), "Cmd+Z"), + ("QWERTZ ⇧ß types ?", &latin, at("Cmd+Shift+?", Key::Minus), "Cmd+?"), + ("QWERTZ + key", &latin, at("Cmd++", Key::CloseBracket), "Cmd++"), + ("QWERTZ ⇧, types ;", &latin, at("Cmd+Shift+;", Key::Comma), "Cmd+;"), + // US. + ("US ⇧;", &latin, at("Shift+:", Key::Semicolon), ":"), + ("US ⇧⌘=", &latin, at("Cmd+Shift++", Key::Equals), "Cmd++"), + ("US ⇧- types _, which has no key", &latin, at("Shift+-", Key::Minus), "Shift+-"), + // macOS "Dvorak – QWERTY ⌘": with ⌘ held the layout's ⌘ table is QWERTY, while the + // logical key egui reports is Dvorak's K. + ("Dvorak – QWERTY ⌘ ⌘V", &latin, at("Cmd+K", Key::V).with_latin('v'), "Cmd+V"), + // A Shift level the platform reports wins over the inference. + ("reported Shift level", &latin, at("Cmd+Shift+/", Key::Num7).with_shift_needed(false), "Cmd+Shift+/"), + ]; + for (name, layout, input, want) in rows { + assert_eq!(layout.resolve(input).map(|c| c.to_string()).as_deref(), Some(*want), "{name}"); + } +} + +#[test] +fn reported_cases_match_only_the_chord_the_press_means() { + let p = Platform::Windows; + let arabic = Layout::new().with_latin(false); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + assert!(arabic.matches(&chord("Ctrl+Shift+D"), &ctrl_shift_d, p)); + assert!(!arabic.matches(&chord("Ctrl+Shift+]"), &ctrl_shift_d, p), "Arabic Ctrl+Shift+D must not run Ctrl+Shift+]"); + + let dvorak_russian_q = at("Cmd+Q", Key::Q).with_latin('\''); + assert!(!Layout::new().with_latin(false).matches(&chord("Ctrl+Q"), &dvorak_russian_q, p), "Dvorak + Russian й must not quit"); + + let qwerty_cmd = at("Cmd+K", Key::V).with_latin('v'); + assert!(Layout::new().matches(&chord("Cmd+V"), &qwerty_cmd, Platform::Mac)); + assert!(!Layout::new().matches(&chord("Cmd+K"), &qwerty_cmd, Platform::Mac), "⌘V must not open the command palette"); + + let german = Layout::new(); + let slash = at("Cmd+Shift+/", Key::Num7); + assert_eq!(texts(&german.candidates(&slash)), ["Cmd+/", "Cmd+Shift+7"]); + assert!(german.matches(&chord("⌘/"), &slash, Platform::Mac)); + // A US-written ⌘? binding is reached with German's ⇧ß, not with ⇧7. + assert!(!german.matches(&chord("Cmd+Shift+/"), &slash, p)); + assert_eq!(texts(&german.candidates(&at("Cmd+Shift+?", Key::Minus))), ["Cmd+?", "Cmd+Shift+/"]); + // German Ctrl+; is Ctrl+Shift+, and must not run a Ctrl+Shift+, (font size) binding. + let semicolon = at("Cmd+Shift+;", Key::Comma); + assert!(german.matches(&chord("Ctrl+;"), &semicolon, p)); + assert!(!german.matches(&chord("Ctrl+Shift+,"), &semicolon, p)); + + // AZERTY's 6 key types -: Ctrl+- first, then Ctrl+6. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+-", Key::Num6))), ["Cmd+-", "Cmd+6"]); + // Zoom keys: + also runs =, and Shift+= runs +. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+Shift++", Key::Equals))), ["Cmd++", "Cmd+Shift+=", "Cmd+="]); + assert_eq!(texts(&Layout::new().candidates(&KeyInput::new(Mods::SHIFT).with_typed(Key::Equals))), ["Shift+=", "+"]); +} + +#[test] +fn typing_decides_whether_the_layout_is_latin() { + let mut layout = Layout::new(); + assert!(layout.is_latin(), "nothing known counts as Latin"); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + // Read as punctuation until the layout is known: ] needs Shift on the D key. + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+]"))); + + layout.observe(Key::D, Mods::NONE, "ي"); + assert!(!layout.is_latin()); + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+Shift+D"))); + + // Punctuation on a letter key says nothing: Hebrew Q and Dvorak Q both type a symbol. + layout.observe(Key::Q, Mods::NONE, "/"); + assert!(!layout.is_latin()); + layout.observe(Key::A, Mods::SHIFT, "A"); + assert!(layout.is_latin()); + layout.observe(Key::W, Mods::NONE, "ς"); + assert!(!layout.is_latin()); + layout.observe(Key::S, Mods::CMD, "s"); + assert!(!layout.is_latin(), "a command chord teaches nothing"); + layout.observe(Key::S, Mods::NONE, "š"); + assert!(layout.is_latin(), "Latin letters beyond ASCII count as Latin"); + layout.observe(Key::M, Mods::NONE, "µ"); + assert!(layout.is_latin(), "Latin-1 symbols that count as letters say nothing"); + + // What the platform reports wins. + let mut reported = Layout::new().with_latin(false); + reported.observe(Key::A, Mods::NONE, "a"); + assert!(!reported.is_latin()); + reported.set_latin(None); + assert!(reported.is_latin()); +} + +#[test] +fn dvorak_shift_punctuation_never_runs_its_position() { + // Dvorak's , key sits on W and types < with Shift, which egui has no key for, so egui + // reports the press as W. + let ctrl_shift_lt = at("Cmd+Shift+W", Key::W); + let mut dvorak = Layout::new(); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "nothing known yet"); + + dvorak.observe(Key::S, Mods::NONE, "o"); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "the W position is unseen"); + dvorak.observe(Key::W, Mods::NONE, ","); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), None); + assert!(!dvorak.matches(&chord("Ctrl+Shift+W"), &ctrl_shift_lt, Platform::Windows)); + // Without Shift, and for letters typed where they sit (Dvorak's A) or elsewhere, presses + // resolve as before. + assert_eq!(dvorak.resolve(&at("Cmd+W", Key::W)), Some(chord("Cmd+W"))); + dvorak.observe(Key::A, Mods::NONE, "a"); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+A", Key::A)), Some(chord("Cmd+Shift+A"))); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+O", Key::S)), Some(chord("Cmd+Shift+O"))); + + // AZERTY: A is moved, but the unseen E position still runs Ctrl+Shift+E. + let mut azerty = Layout::new(); + azerty.observe(Key::Q, Mods::NONE, "a"); + assert_eq!(azerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); + + // QWERTY: a position typing its own letter keeps the position fallback. + let mut qwerty = Layout::new(); + qwerty.observe(Key::S, Mods::NONE, "s"); + qwerty.observe(Key::W, Mods::SHIFT, "W"); + assert_eq!(qwerty.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W"))); + assert_eq!(qwerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); +} + +#[test] +fn a_layout_switch_forgets_what_letter_positions_typed() { + // On Dvorak the W position types `,`; after a switch to QWERTY, Ctrl+Shift+W is W again. + let ctrl_shift_w = at("Cmd+Shift+W", Key::W); + let mut layout = Layout::new().with_latin(true); + layout.observe(Key::W, Mods::NONE, ","); + assert_eq!(layout.resolve(&ctrl_shift_w), None); + layout.set_latin(Some(true)); + assert_eq!(layout.resolve(&ctrl_shift_w), None, "the same report is the same layout"); + + // Dvorak, then Russian, then QWERTY, as the platform reports them. + layout.set_latin(Some(false)); + layout.set_latin(Some(true)); + assert_eq!(layout.resolve(&ctrl_shift_w), Some(chord("Cmd+Shift+W"))); + + // An app that learns of a switch itself. + let mut unreported = Layout::new(); + unreported.observe(Key::W, Mods::NONE, ","); + assert_eq!(unreported.resolve(&ctrl_shift_w), None); + unreported.forget_letters(); + assert_eq!(unreported.resolve(&ctrl_shift_w), Some(chord("Cmd+Shift+W"))); +} + +#[test] +fn labels_show_the_layouts_own_punctuation() { + let mut german = Layout::new().with_char(Key::OpenBracket, 'ü').with_char(Key::Slash, '-').with_char(Key::CloseBracket, '+'); + german.observe(Key::Minus, Mods::NONE, "ß"); + let label = |layout: &Layout, text: &str, p| chord(text).display_for(p, layout); + assert_eq!(label(&german, "Cmd+[", Platform::Windows), "Ctrl+Ü"); + assert_eq!(label(&german, "Cmd+[", Platform::Mac), "⌘Ü"); + // German types - at the / position, so Ctrl+- shows -; its + key types a key of its own. + assert_eq!(label(&german, "Cmd+-", Platform::Windows), "Ctrl+-"); + assert_eq!(label(&german, "Cmd+]", Platform::Windows), "Ctrl+]"); + assert_eq!(label(&german, "Cmd+Z", Platform::Windows), "Ctrl+Z"); + assert_eq!(label(&german, "Cmd+1", Platform::Windows), "Ctrl+1"); + + let russian = Layout::new().with_latin(false).with_char(Key::OpenBracket, 'х'); + assert_eq!(label(&russian, "Cmd+[", Platform::Linux), "Ctrl+Х"); + assert_eq!(label(&russian, "Cmd+C", Platform::Linux), "Ctrl+C"); + assert_eq!(craft_keymap::Trigger::double(chord("[")).display_for(Platform::Linux, &russian), "Х Х"); + assert_eq!(label(&Layout::new(), "Cmd+[", Platform::Linux), "Ctrl+["); +} diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..e3b789b8 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,210 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, Start, plan, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap() -> Keymap { + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), + Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Mac) +} + +#[test] +fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let mut edited = Overrides::new(); + edited.insert("edit.copy", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into()))); + assert_eq!(p.active_name(&builtins), "Custom"); + assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place"); + assert!(p.active_overrides(&builtins).is_empty()); + + p.select("Classic", &builtins).unwrap(); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy"); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into()))); + assert_eq!(builtins[1].1, classic); + + let exported = p.export("Custom 2", &builtins).unwrap(); + assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into())); + assert_eq!(p.active_overrides(&builtins), &edited); + + assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); + assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); + assert_eq!(p.active(), "Mine"); + assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); + p.delete("Mine", &builtins).unwrap(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let wrong = exported.replace("craft-keymap", "other"); + assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat)); + let newer = exported.replace("\"version\": 1", "\"version\": 2"); + assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2))); + + let text = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); + assert_eq!(junk.sets().keys().collect::>(), ["ok"]); + let junk: Profiles = + serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); + assert_eq!(junk.sets()["ok"].get("a"), Some(&["A".to_string()][..])); + assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); +} + +#[test] +fn rename_keeps_a_set_whose_name_differs_only_in_case() { + let builtins = [("Craft", Overrides::new())]; + let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); + assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.sets()["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); + p.delete("mine", &builtins).unwrap(); + assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); +} + +#[test] +fn sets_that_are_not_a_list_or_map_load_as_none() { + for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { + let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(p.sets().is_empty(), "{text}"); + assert_eq!(p.active(), "Mine", "{text}"); + } +} + +#[test] +fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { + let mut mine = Overrides::new(); + mine.insert("edit.copy", vec!["Cmd+K".into()]); + let mut p = Profiles::default(); + p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap(); + + // An update adds a built-in of that name. + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads"); + + let mut edited = mine.clone(); + edited.insert("edit.paste", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into()))); + assert_eq!(p.active_name(&builtins), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &edited); + assert_eq!(p.get("Classic", &builtins), Some(&classic)); + assert_eq!(builtins[1].1, classic); + + let mut q: Profiles = + serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); + assert!(q.reconcile(&builtins)); + assert_eq!(q.active_name(&builtins), "CRAFT 2"); + assert_eq!(q.active_overrides(&builtins), &mine); + assert!(!q.reconcile(&builtins)); +} + +#[test] +fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { + let mut km = keymap(); + km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap(); + km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + p.save_as("Mine", km.overrides().clone(), &builtins).unwrap(); + + let text = p.export("Mine", &builtins).unwrap(); + assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); + assert_eq!(p.active(), "Mine 2"); + + let mut again = keymap(); + again.set_overrides(p.active_overrides(&builtins).clone()); + for bindable in km.catalog() { + assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); + } + assert_eq!(again.overrides(), km.overrides()); +} + +#[test] +fn duplicate_copies_built_in_and_user_sets() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + + assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); + assert_eq!(p.active(), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); + assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); + assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); + assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); + + while p.sets().len() < MAX_SETS { + p.duplicate("Craft", "Spare", &builtins).unwrap(); + } + assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); +} + +#[test] +fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { + let km = keymap(); + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); + let set = ForeignSet::new(vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))]).with_name("Other App"); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; + let planned = plan(&set, &km, &options); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + + let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap(); + assert_eq!(name, "Other App"); + assert_eq!(report.changed.len(), 1); + assert!(km.overrides().is_empty()); + assert_eq!(p.active(), "Other App"); + let mut imported = keymap(); + imported.set_overrides(p.active_overrides(&builtins).clone()); + assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); + + let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); + assert_eq!(name, "Craft 2", "a built-in's name is not reused"); +} + +#[test] +fn loading_lists_the_sets_it_drops_and_the_store_has_a_version() { + let text = r#"{"version": 2, "active": "ok", "sets": [ + {"name": "ok", "overrides": {}}, {"name": " pad ", "overrides": {}}, {"name": "ok", "overrides": {"a": "A"}}, {"name": "bad", "overrides": 3} + ]}"#; + let p: Profiles = serde_json::from_str(text).unwrap(); + assert_eq!(p.sets().keys().collect::>(), ["ok"]); + assert!(p.sets()["ok"].is_empty(), "the first of two sets with one name stays"); + let name = |n: &str| n.to_string(); + assert_eq!( + p.problems(), + [ + SetProblem::NewerVersion(2), + SetProblem::BadName { name: name(" pad ") }, + SetProblem::Duplicate { name: name("ok") }, + SetProblem::BadOverrides { name: name("bad") } + ] + ); + + let many: Vec<_> = (0..MAX_SETS + 3).map(|i| json!({"name": format!("s{i}"), "overrides": {}})).collect(); + let p: Profiles = serde_json::from_value(json!({ "sets": many })).unwrap(); + assert_eq!((p.sets().len(), p.problems()), (MAX_SETS, &[SetProblem::TooMany { ignored: 3 }][..])); + + assert_eq!(serde_json::to_value(&p).unwrap()["version"], 1); + let unversioned: Profiles = serde_json::from_str(r#"{"active": "x", "sets": []}"#).unwrap(); + assert!(unversioned.problems().is_empty()); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs new file mode 100644 index 00000000..2361693e --- /dev/null +++ b/crates/keymap/tests/settings.rs @@ -0,0 +1,146 @@ +use std::time::Duration; + +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Profiles, Scope, Scopes}; +use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct Prefs { + general: General, + #[serde(with = "craft_keymap::keymap::overrides_list")] + shortcuts: Overrides, + profiles: Profiles, +} + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct General { + language: String, +} + +impl Settings for Prefs { + const APP_ID: &'static str = "keymaptest"; + const SCHEMA_VERSION: u32 = 1; +} + +fn open(dir: &std::path::Path) -> SettingsStore { + let probe = Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }; + let options = OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(probe).debounce(Duration::from_millis(10)); + let (store, report) = SettingsStore::open_with(options); + assert!(report.section_errors.is_empty(), "{report:?}"); + store +} + +fn keymap(overrides: Overrides) -> Keymap { + let catalog = vec![ + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()), + Bindable::new("text.bold", "Bold", Scope::app()), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), + Bindable::new("edit.paste", "Paste", Scope::app()).with_default(Binding::parse("Cmd+V").unwrap()), + ]; + Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac).with_overrides(overrides) +} + +#[test] +fn overrides_round_trip_through_craft_settings() { + let tmp = tempfile::tempdir().unwrap(); + let mut km = keymap(Overrides::new()); + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + km.assign(&layers, Binding::parse("Cmd+Shift+L").unwrap(), Policy::Steal).unwrap(); + km.assign(&"text.bold".into(), Binding::parse("Text: Cmd+B").unwrap(), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + + let mut store = open(tmp.path()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.save_as("Mine", km.overrides().clone(), &[]).unwrap(); + p.profiles.save_as("Spare", Overrides::new(), &[]).unwrap(); + }); + store.flush().unwrap(); + + // A hand edit: one text that does not parse, one value that is not a list. + let path = tmp.path().join("config.toml"); + let text = std::fs::read_to_string(&path).unwrap(); + let edit = r#"shortcuts = [{ command = "edit.paste", keys = ["Cmd+Nope"] }, { command = "edit.cut", keys = 5 }, "#; + assert!(text.contains("shortcuts = ["), "{text}"); + std::fs::write(&path, text.replacen("shortcuts = [", edit, 1)).unwrap(); + + let mut store = open(tmp.path()); + let mut km = keymap(store.get().shortcuts.clone()); + assert_eq!(km.bindings(&layers), vec![Binding::parse("Cmd+Shift+L").unwrap()]); + assert_eq!(km.bindings(&"text.bold".into()), vec![Binding::parse("Text: Cmd+B").unwrap()]); + assert!(km.bindings(&"edit.copy".into()).is_empty()); + assert_eq!(km.bindings(&"edit.paste".into()), vec![Binding::parse("Cmd+V").unwrap()], "nothing readable: the defaults"); + assert_eq!(km.overrides().get("edit.paste"), Some(&["Cmd+Nope".to_string()][..])); + let problems = km.problems(); + assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + assert_eq!(store.get().profiles.sets().len(), 2); + + store.update(|p| p.general.language = "de".into()); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("keys = [\"Cmd+Nope\"]"), "{text}"); + assert!( + text.contains(r#"command = "window.panel {\"panel\":\"layers\"}""#) || text.contains(r#"command = 'window.panel {"panel":"layers"}'"#), + "{text}" + ); + assert_eq!(open(tmp.path()).get().shortcuts, store.get().shortcuts); + + // Removals reach the file. + km.reset(&layers); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.delete("Spare", &[]).unwrap(); + }); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); + assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); + let reloaded = open(tmp.path()); + assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); + assert_eq!(reloaded.get().profiles.sets().keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets()["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); +} + +#[test] +fn hand_written_array_of_tables_and_map_forms_load() { + for text in [ + "schema_version = 1\n\n[[shortcuts]]\ncommand = \"edit.copy\"\nkeys = [\"Cmd+K\"]\n\n[[shortcuts]]\ncommand = \"edit.paste\"\nkeys = []\n", + "schema_version = 1\n\n[shortcuts]\n\"edit.copy\" = [\"Cmd+K\"]\n\"edit.paste\" = []\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let km = keymap(open(tmp.path()).get().shortcuts.clone()); + assert_eq!(km.bindings(&"edit.copy".into()), vec![Binding::parse("Cmd+K").unwrap()], "{text}"); + assert!(km.bindings(&"edit.paste".into()).is_empty(), "{text}"); + } +} + +#[test] +fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\nshortcuts = {value}\n\n[general]\nlanguage = \"de\"\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert!(store.get().shortcuts.is_empty(), "{value}"); + assert_eq!(store.get().general.language, "de", "{value}"); + let problems = keymap(store.get().shortcuts.clone()).problems(); + assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); + } +} + +#[test] +fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert_eq!(store.get().profiles.active(), "Mine", "{value}"); + assert!(store.get().profiles.sets().is_empty(), "{value}"); + } +} diff --git a/crates/keymap/tests/support/fonts.rs b/crates/keymap/tests/support/fonts.rs new file mode 100644 index 00000000..d72b576e --- /dev/null +++ b/crates/keymap/tests/support/fonts.rs @@ -0,0 +1,63 @@ +//! Deterministic app-supplied fonts for dialog tests, including their first layout pass. + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +use egui::{FontData, FontDefinitions, FontId}; +use sha2::{Digest, Sha256}; + +// Assets from craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83. Keep the +// input outside this repository; do not fall back to machine-installed fonts. +const INPUTS: [(&str, &str, &str); 2] = [ + ("keymap-symbols", "fonts/noto-sans-symbols/NotoSansSymbols.ttf", "f7e7e04b4a24b6c78893d50cbfd2b2f6cae49617ab047bfef668d252adb128f7"), + ( + "keymap-symbols-2", + "fonts/noto-sans-symbols-2/NotoSansSymbols2-Regular.ttf", + "7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21", + ), +]; + +fn definitions() -> FontDefinitions { + static FONTS: OnceLock = OnceLock::new(); + FONTS + .get_or_init(|| { + let root = PathBuf::from(std::env::var_os("CRAFT_FONTS_DIR").unwrap_or_else(|| { + panic!("UI tests require absolute CRAFT_FONTS_DIR at craft-fonts b076417b5880bd4154bd22f00695b1d28c6bea83; see README.md") + })); + assert!(root.is_absolute(), "CRAFT_FONTS_DIR must be absolute: {}", root.display()); + let mut fonts = FontDefinitions::default(); + assert!(fonts.font_data.contains_key("Hack"), "UI tests require egui default_fonts"); + for (name, relative, expected) in INPUTS { + let path = root.join(relative); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("required font {}: {e}", path.display())); + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), expected, "unreviewed font input: {}", path.display()); + fonts.font_data.insert(name.into(), Arc::new(FontData::from_owned(bytes))); + } + for family in fonts.families.values_mut() { + for name in ["keymap-symbols", "keymap-symbols-2", "Hack"] { + if !family.iter().any(|existing| existing == name) { + family.push(name.into()); + } + } + } + fonts + }) + .clone() +} + +pub fn configure(ctx: &egui::Context) { + ctx.set_fonts(definitions()); +} + +/// All printable ASCII plus the key symbols, dialog icons and accented pseudolocale +/// repertoire. Direction marks are intentionally invisible, rather than drawn glyphs. +pub fn assert_coverage(ctx: &egui::Context) { + const SYMBOLS: &str = "×·—“”…‹›←↑→↓↩↺⇥⇧⌃⌘⌥⌦⌫⎋⏎⌨▾⚠✕⟦⟧ÅÉØáëïöüÜ"; + ctx.fonts_mut(|fonts| { + for font in [FontId::proportional(16.0), FontId::monospace(16.0)] { + for c in (' '..='~').chain(SYMBOLS.chars()) { + assert!(fonts.has_glyph(&font, c), "test font {:?} lacks {c} (U+{:04X})", font.family, u32::from(c)); + } + } + }); +} diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs new file mode 100644 index 00000000..97e6b950 --- /dev/null +++ b/crates/keymap/tests/ui_dialog.rs @@ -0,0 +1,1016 @@ +#![cfg(feature = "ui")] +//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, +//! platform display, accessibility labels, small windows, sets, import review and the summary. + +use std::borrow::Cow; + +use craft_keymap::ui::{ConflictMode, DialogState, English, FixedRow, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::{ + Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, + Platform, Profiles, Scope, Scopes, Trigger, +}; +use egui::accesskit::Role; +use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use ui_fixture::{Pseudo, fixture, with_long_label}; + +#[path = "support/fonts.rs"] +mod test_fonts; +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, + text: Box, + window: bool, + /// Profiles and built-in sets, when the dialog shows sets. + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, + outcomes: Vec, + /// A key press was still in the frame's input after the dialog ran. + keys_left: bool, +} + +/// The dialog of `fx` for a call outside the frame (import_json, review_import). +fn dialog(fx: &mut Fx) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, text, sets, .. } = fx; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) +} + +impl Fx { + fn new(km: Keymap) -> Fx { + Fx { + km, + options: Options::default(), + state: DialogState::new(), + text: Box::new(English), + window: false, + sets: None, + outcomes: Vec::new(), + keys_left: false, + } + } + + fn bindings(&self, id: &str) -> Vec { + self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() + } + + fn canceled(&self) -> bool { + self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) + } +} + +fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { + let mut h = Harness::builder().with_size(size).with_context_setup(test_fonts::configure).build_ui_state( + |ui, fx: &mut Fx| { + test_fonts::assert_coverage(ui.ctx()); + let Fx { km, options, state, text, window, sets, outcomes, keys_left } = fx; + let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let dialog = match sets { + Some((p, b)) => dialog.sets(p, b), + None => dialog, + }; + let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; + outcomes.extend(out); + *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); + }, + fx, + ); + h.run(); + h +} + +fn open(fx: Fx) -> Harness<'static, Fx> { + harness(fx, vec2(1000.0, 700.0)) +} + +fn mac() -> Harness<'static, Fx> { + open(Fx::new(fixture(Platform::Mac))) +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn press(h: &mut Harness<'_, Fx>, modifiers: Modifiers, key: EguiKey) { + h.key_press_modifiers(modifiers, key); + h.run(); +} + +fn raw_key(h: &mut Harness<'_, Fx>, key: EguiKey, time: f64) { + h.input_mut().time = Some(time); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().time = Some(time + 0.01); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn has(h: &Harness<'_, Fx>, label: &str) -> bool { + h.query_by_label(label).is_some() +} + +/// The table itself has focus (a screen reader sees its selected row, the active descendant). +fn table_focused(h: &Harness<'_, Fx>) -> bool { + h.get_by_label("Commands").accesskit_node().is_focused_in_tree() +} + +fn chord(text: &str, platform: Platform) -> String { + Trigger::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")).display(platform) +} + +// ---- Recording ---- + +#[test] +fn recording_captures_a_chord_another_command_would_run() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + assert!(has(&h, "Save: press keys")); + h.state_mut().keys_left = false; + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(!h.state().keys_left, "the dialog took the key press"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "nothing applied before an answer"); +} + +#[test] +fn recording_takes_tab_arrows_and_shift_escape() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert_eq!(h.state().bindings("file.save"), ["Tab"]); + assert!(table_focused(&h), "Tab did not move focus"); + + click(&mut h, &format!("Save: {}", chord("Tab", Platform::Mac))); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert_eq!(h.state().bindings("file.save"), ["Down"]); + + click(&mut h, &format!("Save: {}", chord("Down", Platform::Mac))); + press(&mut h, Modifiers::SHIFT, EguiKey::Escape); + assert_eq!(h.state().bindings("file.save"), ["Shift+Escape"]); + assert!(table_focused(&h)); + assert!(h.state().state.is_open()); +} + +#[test] +fn escape_cancels_recording_only() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().state.is_open()); + assert!(!h.state().state.is_recording()); + assert!(!h.state().canceled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().canceled()); + assert!(!h.state().state.is_open()); +} + +#[test] +fn copy_event_records_cmd_c() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + h.event(Event::Copy); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+C"]); +} + +#[test] +fn double_press_records_k_k() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.double_press = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + raw_key(&mut h, EguiKey::K, 10.0); + assert!(has(&h, "Save: K (press again for a double press)")); + raw_key(&mut h, EguiKey::K, 10.3); + assert_eq!(h.state().bindings("file.save"), ["K K"]); + + click(&mut h, "Save: K K"); + raw_key(&mut h, EguiKey::K, 20.0); + raw_key(&mut h, EguiKey::K, 20.9); + assert_eq!(h.state().bindings("file.save"), ["K"]); +} + +#[test] +fn the_new_shortcut_field_waits_for_assign() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(has(&h, "Currently assigned to:")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Assign"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + click(&mut h, "Keep Both"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); +} + +/// Presses Tab until the control labeled `label` has focus; false after `max` presses. +fn tab_to(h: &mut Harness<'_, Fx>, label: &str, max: usize) -> bool { + for _ in 0..max { + press(h, Modifiers::NONE, EguiKey::Tab); + if h.get_by_label(label).is_focused() { + return true; + } + } + false +} + +#[test] +fn tab_leaves_the_new_shortcut_field_after_recording() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("New shortcut: F7").is_focused()); + assert!(tab_to(&mut h, "Assign", 6), "Tab moves on to Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "Tab is not recorded"); +} + +#[test] +fn tab_leaves_search_by_key_after_recording() { + let mut h = mac(); + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("Search by key").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert!(table_focused(&h), "Tab moves on to the table"); +} + +// ---- Conflicts ---- + +#[test] +fn conflict_take_it_steals_and_undo_restores() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It"); + assert_eq!(h.state().bindings("file.save"), Vec::::new()); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + assert!(has(&h, "⌘S was removed from Save")); + + // The footer's Undo is after the message's. + let undo = h.get_all_by_label("Undo").last().map(|n| n.rect()); + let Some(undo) = undo else { panic!("no Undo") }; + h.get_all_by_label("Undo").filter(|n| n.rect() == undo).for_each(|n| n.click()); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]); + click(&mut h, "Redo"); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"], "Cmd+Z undoes when no text field has focus"); +} + +#[test] +fn conflict_keep_both_lists_the_clash() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Keep Both"); + let clashes = h.state().km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + assert!(has(&h, "Also used by Save")); + click(&mut h, "⚠ 1 shortcut is assigned more than once"); + assert!(has(&h, "Save: ⌘S")); + assert!(has(&h, "Export…: ⌘S")); + assert!(!has(&h, "Save As…: ⇧⌘S"), "the Conflicts filter shows only the clash"); +} + +#[test] +fn steal_mode_says_what_it_took() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.conflicts = ConflictMode::Steal; + let mut h = open(fx); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "⌘S was removed from Save")); + click(&mut h, "Go to Conflict"); + assert!(selected(&h, "Save (Changed)")); +} + +/// Whether a row with this label reports itself selected. +fn selected(h: &Harness<'_, Fx>, label: &str) -> bool { + h.get_all_by_label(label).any(|n| n.accesskit_node().is_selected() == Some(true)) +} + +#[test] +fn go_to_conflict_selects_and_reveals_the_holder() { + let mut h = mac(); + choose_area(&mut h, "Tools"); + search(&mut h, "marquee"); + assert!(!has(&h, "Save: ⌘S")); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It and Go to Conflict"); + assert_eq!(h.state().bindings("tool.marquee"), ["Cmd+S"]); + assert!(selected(&h, "Save (Changed)"), "the holder is selected"); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), ""); + assert!(has(&h, "Export…: ⌘E"), "area All, no filter"); +} + +#[test] +fn reserved_chord_is_refused() { + let km = fixture(Platform::Mac).with_reserved(vec![Chord::parse("Cmd+Tab").unwrap_or_else(|e| panic!("{e}"))]); + let mut h = open(Fx::new(km)); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::COMMAND, EguiKey::Tab); + assert!(has(&h, &format!("⚠ {} is reserved by the system", chord("Cmd+Tab", Platform::Mac)))); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn app_rule_refuses() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.accept = Some(Box::new(|b, binding| { + if matches!(b.kind, Kind::Tool { .. }) && !binding.trigger.chord.mods.is_none() { + Err("Tools take keys without modifiers".into()) + } else { + Ok(()) + } + })); + let mut h = open(fx); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::K); + assert!(has(&h, "⚠ Tools take keys without modifiers")); + assert_eq!(h.state().bindings("tool.marquee"), ["M"]); +} + +// ---- Search, areas, display ---- + +fn search(h: &mut Harness<'_, Fx>, text: &str) { + let field = h.get_by_label("Search:"); + field.focus(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::A); + h.run(); + h.get_by_label("Search:").type_text(text); + h.run(); +} + +fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + // The popup's item, after a group header of the same name. + h.get_all_by_role_and_label(Role::Button, area).last().unwrap_or_else(|| panic!("{area}")).click(); + h.run(); +} + +struct German; +impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if n == 1 { one } else { other }.replace("{n}", &n.to_string()) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.save" { "Sichern".into() } else { b.label.as_str().into() } + } +} + +#[test] +fn search_by_name_translation_or_chord() { + let mut h = mac(); + search(&mut h, "save"); + assert!(has(&h, "Save: ⌘S") && has(&h, "Save As…: ⇧⌘S")); + assert!(!has(&h, "Export…: ⌘E")); + search(&mut h, "cmd+s"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "⌘S"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "zzz"); + assert!(has(&h, "No matching commands")); + + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::S); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "⇧⌘S"); + assert!(has(&h, "Save As…: ⇧⌘S") && !has(&h, "Save: ⌘S")); + + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(German); + let mut h = open(fx); + search(&mut h, "sichern"); + assert!(has(&h, "Sichern: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); +} + +#[test] +fn areas_follow_paths_kind_and_scope() { + let mut h = mac(); + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { + assert!(h.get_all_by_label(area).count() >= 1, "{area}"); + } + h.get_all_by_role_and_label(Role::Button, "Tools").last().expect("the Tools item").click(); + h.run(); + assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); + assert!(!has(&h, "Save: ⌘S")); + choose_area(&mut h, "Timeline Panel"); + assert!(has(&h, "Loop: L") && !has(&h, "Marquee Tool: M")); +} + +#[test] +fn display_follows_the_keymap_platform() { + let h = mac(); + assert!(has(&h, "Save As…: ⇧⌘S")); + assert!(has(&h, "Bold: ⌘B (Text)"), "a scope other than home is named"); + let h = open(Fx::new(fixture(Platform::Windows))); + assert!(has(&h, "Save As…: Ctrl+Shift+S")); +} + +// ---- Labels, focus, small windows, RTL ---- + +#[test] +fn every_control_has_a_label() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "Take It")); + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + if matches!(n.role(), Role::Button | Role::Tab | Role::TextInput | Role::ComboBox) { + seen += 1; + assert!(n.label().is_some_and(|l| !l.trim().is_empty()), "{:?} without a label at {:?}", n.role(), node.rect()); + } + } + assert!(seen > 20, "{seen} controls"); +} + +#[test] +fn table_keys_move_record_and_delete() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(table_focused(&h)); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert!(selected(&h, "Save As…")); + press(&mut h, Modifiers::NONE, EguiKey::Delete); + assert_eq!(h.state().bindings("file.saveAs"), Vec::::new()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(h.state().state.is_recording()); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); +} + +#[test] +fn group_headers_take_focus_and_toggle_with_enter() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(tab_to(&mut h, "File", 3)); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "Export…: ⌘E"), "File is collapsed"); + assert!(h.get_by_label("File").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "Export…: ⌘E")); +} + +#[test] +fn table_names_the_selected_row_and_messages_are_live() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + let row = h.get_by_role_and_label(Role::Button, "Save").accesskit_node().id(); + assert_eq!(h.get_by_label("Commands").accesskit_node().active_descendant().map(|n| n.id()), Some(row)); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert_eq!(h.get_by_label("⚠ ⌘S is used by Save").accesskit_node().live(), egui::accesskit::Live::Polite); +} + +#[test] +fn small_window_keeps_ok_and_long_labels_truncate() { + let long = "A very long command label ".repeat(12); + let mut km = fixture(Platform::Mac); + let mut catalog = km.catalog().to_vec(); + catalog.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut fx = Fx::new(km); + fx.window = true; + let size = vec2(480.0, 360.0); + let mut h = harness(fx, size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let ok = h.get_by_label("OK").rect(); + assert!(screen.contains_rect(ok), "OK at {ok:?}"); + let row = h.get_by_label(long.trim()); + assert!(row.rect().max.x <= size.x + 0.5, "the row stays inside: {:?}", row.rect()); + row.hover(); + h.run(); + assert!(h.query_all_by_label_contains(long.trim()).count() >= 2, "the tooltip has the full label"); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); +} + +#[test] +fn window_close_is_cancel() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.window = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert_eq!(h.state().bindings("file.save"), ["F2"]); + click(&mut h, "Cancel"); + assert!(h.state().canceled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + let after: Vec = h.state().outcomes.iter().skip_while(|o| !matches!(o, Outcome::Cancel)).map(|o| matches!(o, Outcome::Changed)).collect(); + assert_eq!(after, [false, true], "Changed follows Cancel, which undid an edit"); + assert!(!h.state().state.is_open()); +} + +#[test] +fn automation_ids_are_reported() { + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + let sink = seen.clone(); + fx.options.on_widget = Some(Box::new(move |id, rect, label| { + assert!(rect.is_finite() && !label.is_empty(), "{id}"); + sink.borrow_mut().push(id.to_string()); + })); + let mut h = open(fx); + h.get_by_label("Save").click(); + h.run(); + let ids = seen.borrow(); + for id in [ + "shortcuts.area", + "shortcuts.filter", + "shortcuts.search", + "shortcuts.searchKey", + "shortcuts.row.file.save", + "shortcuts.cell.file.save.0", + "shortcuts.add.file.save", + "shortcuts.reset.file.save", + "shortcuts.remove.0", + "shortcuts.new", + "shortcuts.context", + "shortcuts.assign", + "shortcuts.clear", + "shortcuts.undo", + "shortcuts.redo", + "shortcuts.resetAll", + "shortcuts.cancel", + "shortcuts.ok", + "shortcuts.keyboard", + "shortcuts.mod.cmd", + "shortcuts.key.F7", + ] { + assert!(ids.iter().any(|s| s == id), "{id} not reported"); + } +} + +#[test] +fn typed_entry_parses_and_shows_errors() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Nope"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "⚠ unknown key Nope")); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").focus(); + h.run(); + press(&mut h, Modifiers::COMMAND, EguiKey::A); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "⚠ unknown key Nope")); + click(&mut h, "Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); +} + +struct Rtl; +impl Text for Rtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, _n: u64, _one: &str, other: &str) -> String { + other.to_string() + } + fn rtl(&self) -> bool { + true + } +} + +#[test] +fn rtl_puts_the_detail_pane_left_and_keeps_chords() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(Rtl); + let h = open(fx); + let table = h.get_by_label("Commands").rect(); + assert!(table.min.x > 300.0, "table on the right: {table:?}"); + let chip = h.get_by_label("Save As…: ⇧⌘S").rect(); + assert!(chip.center().x < table.center().x, "shortcut column on the table's left: {chip:?} in {table:?}"); +} + +// ---- Sets ---- + +fn with_sets(profiles: Profiles) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((profiles, vec![("Fixture Defaults", Overrides::new())])); + open(fx) +} + +fn profiles<'h>(h: &'h Harness<'_, Fx>) -> &'h Profiles { + &h.state().sets.as_ref().expect("sets").0 +} + +/// The last control with this label: a prompt's button rather than the set row's. +fn click_last(h: &mut Harness<'_, Fx>, label: &str) { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("{label}")).click(); + h.run(); +} + +fn new_set(h: &mut Harness<'_, Fx>, name: &str) { + click(h, "New Set…"); + h.get_by_role_and_label(Role::TextInput, "Name:").type_text(name); + h.run(); + click(h, "Create"); +} + +#[test] +fn cancel_restores_keymap_and_sets() { + let mut p = Profiles::default(); + p.save_as("Mine", Overrides::new(), &[("Fixture Defaults", Overrides::new())]).expect("save_as"); + let before = p.clone(); + let mut h = with_sets(p); + click(&mut h, "Set:"); + click_last(&mut h, "Mine"); + assert_eq!(profiles(&h).active(), "Mine"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert!(has(&h, "(modified)")); + new_set(&mut h, "Other"); + assert_eq!(profiles(&h).active(), "Other"); + assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + click_last(&mut h, "Delete"); + assert!(!profiles(&h).sets().contains_key("Other")); + click(&mut h, "Cancel"); + assert!(h.state().canceled()); + assert_eq!(profiles(&h), &before); + assert!(h.state().km.overrides().is_empty()); +} + +#[test] +fn an_open_prompt_holds_the_set_row_table_and_edit_actions() { + let mut h = with_sets(Profiles::default()); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + new_set(&mut h, "Other"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + for label in ["Set:", "New Set…", "Save: F2", "Undo", "Reset All…", "OK"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); + } + assert!(h.get_all_by_label("Cancel").all(|n| !n.accesskit_node().is_disabled()), "the prompt's and the dialog's"); + h.get_all_by_label("Cancel").next().expect("the prompt's Cancel").click(); + h.run(); + assert!(h.state().state.is_open()); + assert!(!h.get_by_label("OK").accesskit_node().is_disabled()); +} + +#[test] +fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut h = with_sets(Profiles::default()); + assert!(h.get_by_label("Rename…").accesskit_node().is_disabled(), "built-ins cannot be renamed"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + let p = profiles(&h); + assert_eq!(p.active(), "Custom"); + assert_eq!(p.sets().get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); +} + +#[test] +fn set_export_import_round_trip() { + let mut h = with_sets(Profiles::default()); + new_set(&mut h, "Name"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + h.get_all_by_label("Export…").next().expect("the set row's Export…").click(); + h.run(); + let (name, json) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Export { name, json } = o { Some((name.clone(), json.clone())) } else { None }) + .expect("Outcome::Export"); + assert_eq!(name, "Name"); + let edited = h.state().km.overrides().clone(); + + let mut fresh = with_sets(profiles(&h).clone()); + let (d, state) = dialog(fresh.state_mut()); + assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); + fresh.run(); + assert_eq!(profiles(&fresh).active(), "Name 2"); + assert_eq!(fresh.state().km.overrides(), &edited); + assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); +} + +fn foreign_set() -> ForeignSet { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs") +} + +fn review(h: &mut Harness<'_, Fx>) { + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = dialog(h.state_mut()); + d.review_import(state, foreign_set(), options); + h.run(); +} + +#[test] +fn foreign_import_review_lists_skipped_and_unreadable() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(has(&h, "Import “Theirs”")); + for (item, line) in [("File > Save", 3), ("File > Frobnicate", 4), ("File > Export…", 5), ("Object > Arrange > Bring to Front", 6)] { + let found = h.query_all_by_label_contains(item).any(|n| n.accesskit_node().value().is_some_and(|l| l.contains(&format!("line {line}")))); + assert!(found, "{item} with line {line}"); + } + assert!(h.query_by_label_contains("unknown key").is_some(), "the unreadable key says why"); + assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); + assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert_eq!(profiles(&h).active(), "Theirs"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); + assert!(has(&h, "Imported as “Theirs”")); + click(&mut h, "Done"); + assert!(h.state().state.is_open() && has(&h, "Commands")); +} + +#[test] +fn picking_a_suggestion_replans_and_is_remembered() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(h.query_by_label_contains("3 commands will change").is_some()); + click(&mut h, "Command for File > Sve Az"); + click_last(&mut h, "Save As…"); + let remembered = h.state().outcomes.iter().find_map(|o| if let Outcome::Remembered(m) = o { Some(m.clone()) } else { None }); + assert!(remembered.is_some_and(|m| m.values().any(|c| c.id == "file.saveAs"))); + assert!(h.query_by_label_contains("4 commands will change").is_some()); + assert!(h.query_by_label_contains("File > Sve Az · line 7 — Save As…: ").is_some(), "the pick moved to Will change"); + assert!(h.query_by_label("Command for File > Sve Az").is_none()); +} + +// ---- Summary ---- + +#[test] +fn summary_escapes_html_and_keeps_text_lines() { + let mut km = fixture(Platform::Mac); + let mut catalog = km.catalog().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + catalog.push( + Bindable::new("edit.tricky", label, Scope::app()) + .with_path(vec!["Edit".into(), "Tricky".into()]) + .with_default(craft_keymap::Binding::parse("F6").expect("F6")), + ); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut h = open(Fx::new(km)); + click(&mut h, "Summarize…"); + let (html, text) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Summary { html, text, .. } = o { Some((html.clone(), text.clone())) } else { None }) + .expect("Outcome::Summary"); + assert!(html.contains("<b>"Tom" & 'Jerry'</b>"), "{html}"); + assert!(!html.contains("")); + assert!(html.contains("⌘S")); + assert!(text.lines().any(|l| l == " \"Tom\" & 'Jerry' Next\tF6"), "{text}"); + assert!(text.lines().any(|l| l == " Save\t⌘S"), "{text}"); +} + +#[test] +fn narrow_set_row_moves_actions_into_more() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(560.0, 600.0)); + assert!(h.query_by_label("Summarize…").is_none(), "Summarize… is in More"); + click(&mut h, "More"); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); +} + +// ---- On-screen keyboard, fixed rows, read-only ---- + +fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + options(&mut fx); + open(fx) +} + +/// What a key's color means, as its accessibility description says it. +fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { + h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() +} + +#[test] +fn keyboard_colors_keys_and_assigns_to_the_selected_command() { + let mut h = with_keyboard(|_| {}); + assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); + assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); + assert_eq!(holders_class(&h, "F7"), "Not assigned"); + + click(&mut h, "⌘ Command"); + assert_eq!(holders_class(&h, "⌘S: Save"), "Application"); + assert_eq!(holders_class(&h, "⌘B: Bold (Text)"), "Panel or context"); + // Held modifiers add to the toggles. + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.run(); + assert!(has(&h, "⇧⌘S: Save As…")); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run(); + click(&mut h, "⌘ Command"); + + click(&mut h, "L: Loop (Timeline)"); + assert!(has(&h, "Key: L")); + assert!(h.get_by_label("Assign to Selected").accesskit_node().is_disabled(), "no command selected"); + click(&mut h, "Save"); + click(&mut h, "Assign to Selected"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "L"]); + assert_eq!(holders_class(&h, "L: Save, Loop (Timeline)"), "Both"); + + // A holder in the key list selects its command. + click(&mut h, "Loop (Timeline)"); + assert!(selected(&h, "Loop")); +} + +#[test] +fn fixed_rows_are_struck_through_when_a_command_takes_their_key() { + let mut fx = Fx::new(fixture(Platform::Mac)); + let row = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.map(|c| Trigger::parse(c).unwrap_or_else(|e| panic!("{c}: {e}"))), + }; + fx.options.fixed = vec![row("Rate 1", "1", Some("1")), row("Rate 2", "2", Some("2")), row("Pan", "Space (hold)", None)]; + let mut h = open(fx); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1")); + assert!(has(&h, "Pan: Space (hold)")); + + choose_area(&mut h, "All Commands"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Num1); + assert_eq!(h.state().bindings("file.save"), ["1"]); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1 (Now used by Save)")); + assert!(has(&h, "Rate 2: 2")); +} + +#[test] +fn read_only_has_no_edit_controls() { + let mut h = with_keyboard(|fx| { + fx.options.editable = false; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + }); + click(&mut h, "Save"); + click(&mut h, "Save: ⌘S"); + assert!(!has(&h, "Save: press keys"), "no recording"); + click(&mut h, "S"); + assert!(has(&h, "Key: S")); + for label in [ + "Undo", + "Redo", + "Reset All…", + "OK", + "Cancel", + "Set:", + "New Set…", + "Import…", + "Add a shortcut to Save", + "Restore the default for Save", + "Remove ⌘S from Save", + "New shortcut:", + "Context:", + "Assign", + "Use Default", + "Clear", + "Assign to Selected", + ] { + assert!(!has(&h, label), "{label} is shown"); + } + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); + click(&mut h, "Close"); + assert!(h.state().canceled()); +} + +// ---- Pseudo locale ---- + +/// Text a test put there or read from a foreign file, which no lookup translates. +const RAW: &[&str] = &["Theirs", "Frobnicate", "Sve Az", "Blorp", "Num", "Name 2"]; + +/// Every control inside the `width` px dialog, and every label with words marked by `Pseudo`. +fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let Some(label) = n.label() else { continue }; + seen += 1; + let r = node.rect(); + assert!(r.min.x >= -1.0 && r.max.x <= width + 1.0, "{step}: {:?} {label:?} at {r:?} leaves the {width} px dialog", n.role()); + let mut rest = String::new(); + let mut depth = 0usize; + for c in label.chars() { + match c { + '⟦' => depth += 1, + '⟧' => depth = depth.saturating_sub(1), + _ if depth == 0 => rest.push(c), + _ => {} + } + } + for raw in RAW { + rest = rest.replace(raw, ""); + } + let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); + assert!(!words, "{step}: {label:?} has text that did not go through Text"); + } + assert!(seen > 15, "{step}: {seen} labeled nodes"); +} + +#[test] +fn pseudo_locale_marks_every_string_and_fits() { + let p = Pseudo::mark; + // Each direction at one width: the narrow layout left to right, the wide one right to left. + for (rtl, width) in [(false, 560.0), (true, 1100.0)] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); + } +} + +#[test] +fn an_app_error_shows_until_dismissed() { + let mut h = mac(); + h.state_mut().state.show_error("The file could not be read"); + h.run(); + assert!(has(&h, "⚠ The file could not be read")); + click(&mut h, "Dismiss"); + assert!(!has(&h, "⚠ The file could not be read")); +} + +#[test] +fn one_pointer_click_on_a_chip_of_another_row_records() { + let mut h = mac(); + click(&mut h, "Save"); + let at = h.get_by_label("Export…: ⌘E").rect().center(); + h.event(Event::PointerMoved(at)); + h.run(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run(); + assert!(has(&h, "Export…: press keys")); +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs new file mode 100644 index 00000000..f2f930cc --- /dev/null +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -0,0 +1,156 @@ +//! The catalog the dialog tests and screenshots use. + +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use craft_keymap::ui::{PLURALS, STRINGS, Text}; +use craft_keymap::{Bindable, Binding, Key, Keymap, Kind, Platform, Scope, Scopes}; + +fn binding(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +pub fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalog = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), + ]; + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) +} + +/// `km` with a command whose label and menu path are far too long for a row. +#[allow(dead_code)] +pub fn with_long_label(km: Keymap) -> Keymap { + let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); + let mut catalog = km.catalog().to_vec(); + catalog.insert( + 3, + Bindable::new("object.distribute", long.as_str(), Scope::app()) + .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) + .with_default(binding("Cmd+Alt+Shift+D")) + .with_default(binding("Ctrl+Alt+Shift+F12")), + ); + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) +} + +/// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and +/// in `⟦…⟧`, so text drawn without the marks never went through [`Text`]. Placeholders stay as +/// they are. Labels and path parts are marked too. Looked-up dialog strings missing from +/// `STRINGS` or `PLURALS` are collected in `unlisted`. +#[allow(dead_code)] +#[derive(Default)] +pub struct Pseudo { + pub unlisted: Rc>>, + /// Lay the dialog out right to left. + pub rtl: bool, +} + +#[allow(dead_code)] +impl Pseudo { + pub fn mark(source: &str) -> String { + let mut out = String::from("⟦\u{200F}"); + let mut in_placeholder = false; + let mut letters = 0; + for c in source.chars() { + match c { + '{' => in_placeholder = true, + '}' => in_placeholder = false, + _ => {} + } + let accented = match c { + _ if in_placeholder => c, + 'a' => 'á', + 'e' => 'ë', + 'i' => 'ï', + 'o' => 'ö', + 'u' => 'ü', + 'A' => 'Å', + 'E' => 'É', + 'O' => 'Ø', + _ => c, + }; + if !in_placeholder && c.is_alphabetic() { + letters += 1; + } + out.push(accented); + } + out.extend(std::iter::repeat_n('~', letters * 2 / 5)); + out.push_str("\u{200F}⟧"); + out + } +} + +impl Text for Pseudo { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + if !STRINGS.contains(&source) { + self.unlisted.borrow_mut().insert(source.to_string()); + } + Pseudo::mark(source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if !PLURALS.contains(&(one, other)) { + self.unlisted.borrow_mut().insert(format!("{one}|{other}")); + } + Pseudo::mark(if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + Pseudo::mark(&b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + Pseudo::mark(s).into() + } + + /// Key names spelled out in words (Escape, Space), on every platform. + fn key(&self, k: Key, _p: Platform) -> Option { + let name = k.to_string(); + name.chars().any(|c| c.is_ascii_lowercase()).then(|| Pseudo::mark(&name)) + } + + fn rtl(&self) -> bool { + self.rtl + } +} + +/// English laid out right to left, to look at the mirrored layout. +#[allow(dead_code)] +pub struct EnglishRtl; + +impl Text for EnglishRtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + (if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn rtl(&self) -> bool { + true + } + + /// The words stay in English order; spaces at either end change sides, as in a right-to-left + /// paragraph. + fn visual(&self, s: &str) -> String { + let core = s.trim(); + if core.is_empty() { + return s.to_string(); + } + let start = s.len() - s.trim_start().len(); + let end = s.trim_end().len(); + format!("{}{core}{}", &s[end..], &s[..start]) + } +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs new file mode 100644 index 00000000..885d78aa --- /dev/null +++ b/crates/keymap/tests/ui_snapshot.rs @@ -0,0 +1,243 @@ +#![cfg(feature = "snapshot")] +//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With +//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×; +//! without it the test does nothing. +//! +//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` +//! +//! Uses the same hash-verified `CRAFT_FONTS_DIR` input as ui_dialog (see README.md). +//! Fonts are installed before the first layout pass; missing glyphs remain errors. + +use std::path::PathBuf; + +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog, Text}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; +use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ui_fixture::{EnglishRtl, Pseudo, fixture, with_long_label}; + +#[path = "support/fonts.rs"] +mod test_fonts; +mod ui_fixture; + +struct Fx { + km: Keymap, + text: Box, + options: Options, + state: DialogState, + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, text, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) + } +} + +/// A named state of the dialog: window size and what to do before the picture. +struct Case { + name: &'static str, + size: Vec2, + platform: Platform, + /// Show named sets. + sets: bool, + /// Show the on-screen keyboard. + keyboard: bool, + words: Words, + /// Add a command with a very long label and name the user set at length. + long: bool, + act: fn(&mut Harness<'_, Fx>), +} + +#[derive(Clone, Copy)] +enum Words { + English, + /// English laid out right to left. + Rtl, + /// The pseudo translation of the tests, left to right. + Pseudo, +} + +impl Case { + const fn new(name: &'static str, width: f32, height: f32, act: fn(&mut Harness<'_, Fx>)) -> Case { + Case { name, size: Vec2::new(width, height), platform: Platform::Mac, sets: false, keyboard: false, words: Words::English, long: false, act } + } + + const fn windows(mut self) -> Case { + self.platform = Platform::Windows; + self + } + + const fn sets(mut self) -> Case { + self.sets = true; + self + } + + const fn keyboard(mut self) -> Case { + self.keyboard = true; + self + } + + const fn words(mut self, words: Words) -> Case { + self.words = words; + self + } + + const fn long(mut self) -> Case { + self.long = true; + self + } +} + +fn foreign_review(h: &mut Harness<'_, Fx>) { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + let set = ForeignSet::new(vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ]) + .with_name("Theirs"); + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = h.state_mut().dialog(); + d.review_import(state, set, options); + h.run(); +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn select(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +const CASES: &[Case] = &[ + Case::new("selected", 1000.0, 640.0, |h| select(h, "Save As…")), + Case::new("conflict", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }), + Case::new("recording", 1000.0, 640.0, |h| click(h, "Save: Ctrl+S")).windows(), + Case::new("narrow", 560.0, 640.0, |h| select(h, "Save")), + Case::new("sets", 1000.0, 640.0, |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }) + .sets(), + Case::new("sets-narrow", 560.0, 640.0, |h| click(h, "More")).sets(), + Case::new("keyboard", 1100.0, 720.0, |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }) + .keyboard(), + Case::new("keyboard-windows", 860.0, 640.0, |h| click(h, "L: Loop (Timeline)")).windows().keyboard(), + Case::new("review", 1000.0, 640.0, foreign_review).sets(), + Case::new("long", 1000.0, 640.0, |h| { + let state = &mut h.state_mut().state; + state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("long-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("rtl", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }) + .sets() + .long() + .words(Words::Rtl), + Case::new("rtl-narrow", 560.0, 640.0, |h| select(h, "Bold")).words(Words::Rtl).long(), + Case::new("rtl-keyboard", 1100.0, 720.0, |h| click(h, "L: Loop (Timeline)")).keyboard().words(Words::Rtl), + Case::new("rtl-review", 1000.0, 640.0, foreign_review).sets().words(Words::Rtl), + Case::new("pseudo", 1100.0, 720.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("file.save")); + h.run(); + }) + .sets() + .keyboard() + .long() + .words(Words::Pseudo), + Case::new("pseudo-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long() + .words(Words::Pseudo), +]; + +#[test] +fn dialog_screenshots() { + let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. + let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { + for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { + for scale in [1.0, 2.0] { + let sets = case.sets.then(|| { + let mut p = Profiles::default(); + let builtins = vec![("Fixture Defaults", Overrides::new())]; + if case.long { + let name = "My Shortcuts for Long Evenings of Retouching Photographs"; + if let Err(e) = p.save_as(name, Overrides::new(), &builtins).and_then(|n| p.select(&n, &builtins)) { + panic!("{name}: {e}"); + } + } + (p, builtins) + }); + let options = Options::default().with(|o| o.keyboard = case.keyboard); + let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; + let text: Box = match case.words { + Words::English => Box::new(English), + Words::Rtl => Box::new(EnglishRtl), + Words::Pseudo => Box::new(Pseudo::default()), + }; + let fx = Fx { km, text, options, state: DialogState::new(), sets }; + let mut h = Harness::builder() + .with_size(case.size) + .with_pixels_per_point(scale) + .with_theme(theme) + .with_context_setup(test_fonts::configure) + .wgpu() + .build_ui_state( + |ui, fx: &mut Fx| { + test_fonts::assert_coverage(ui.ctx()); + egui::Frame::central_panel(ui.style()).show(ui, |ui| { + let (d, state) = fx.dialog(); + d.ui(ui, state); + }); + }, + fx, + ); + h.run(); + (case.act)(&mut h); + let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); + let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name)); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } + } +} diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 9f27f989..554e129b 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -1,4 +1,5 @@ -//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a (MIT OR Apache-2.0). +//! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a and +//! a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0). #![cfg(feature = "raster")] use craft_pdf::{ open_slice, @@ -269,3 +270,161 @@ fn tiff_predictor_packed_rgb_image_renders_every_row() { assert_eq!(pixel(10), &[255, 0, 0, 255]); assert_eq!(pixel(30), &[0, 255, 0, 255]); } + +// An 8 x 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: one horizontal-mode row +// (white 0, black 8), three V0 rows, then EOFB. +const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + +fn fax_page(space: &str, params: &str) -> Pixels { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {params} >> /Length {} >> stream\n", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + render(&pdf) +} + +fn pixel(page: &Pixels, x: u32, y: u32) -> &[u8] { + let i = ((y * page.width + x) * 4) as usize; + &page.rgba[i..i + 4] +} + +/// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11 +/// defines as "height not predetermined" (the default), not as zero rows. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn group4_fax_with_rows_zero_decodes_to_the_image_height() { + for rows in ["", "/Rows 4", "/Rows 0"] { + let page = fax_page("/DeviceGray", rows); + assert_eq!(pixel(&page, 15, 24), &[0, 0, 0, 255], "{rows:?}: the fax image paints black"); + assert_eq!(pixel(&page, 1, 38), &[255, 255, 255, 255], "{rows:?}: outside the image stays white"); + } +} + +/// PdfCraft #3308: a fax image whose `/Rows` is below its `/Height` is decoded to its height. +/// `/BlackIs1` turns the fixture white, so rows left undecoded show as black. Also through an +/// `/Indexed` color space, the filter's other output path. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() { + for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] { + let page = fax_page(space, &format!("{rows} /BlackIs1 true")); + for y in [17, 22, 27, 32] { + assert_eq!(pixel(&page, 15, y), &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded"); + } + } +} + +/// PdfCraft ledger hayro-interpret P2 (#3178): a shaded Type 3 glyph clips to its `/FontBBox` in +/// device space at every position, not to the glyph-space box. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shaded_type3_glyph_draws_at_multiple_positions() { + let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET"; + let glyph = "100 0 d0 0 0 100 100 re W n /S sh"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len() + ); + let page = render(pdf.as_bytes()); + for x in [65, 235] { + let px = pixel(&page, x, 65); + assert!(px[0] < 240 && px[2] < 240 && px[1] < 10, "glyph at {x}: {px:?}"); + } +} + +/// PdfCraft ledger hayro-interpret (26) extension: comma-below letters in non-embedded standard +/// fonts are the letter with the font's comma under it, apart from the letter and inside the +/// descender, whichever name the font gives them (`/scommaaccent`, `/Kcedilla`, `/uni0219`). +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() { + let pairs = [ + ("s", "uni0219"), + ("t", "uni021B"), + ("T", "uni021A"), + ("K", "uni0136"), + ("k", "kcedilla"), + ("G", "uni0122"), + ("G", "Gcedilla"), + ("R", "uni0156"), + ("s", "scommaaccent"), + ("S", "Scommaaccent"), + ("t", "tcommaaccent"), + ("T", "Tcommaaccent"), + ("k", "kcommaaccent"), + ("K", "Kcommaaccent"), + ("l", "lcommaaccent"), + ("L", "Lcommaaccent"), + ("n", "ncommaaccent"), + ("N", "Ncommaaccent"), + ("r", "rcommaaccent"), + ("R", "Rcommaaccent"), + ("G", "Gcommaaccent"), + ]; + let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect(); + let (size, baseline) = (40u32, 60u32); + for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] { + let mut content = String::new(); + for i in 0..letters.len() { + content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i)); + } + let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content}endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /{font} + /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + 20 + 40 * letters.len(), + content.len() + ); + let p = render(pdf.as_bytes()); + // The rows (y down) of the i-th letter that have dark pixels. + let ink_rows = |i: u32| -> Vec { + (0..p.height).filter(|&y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| pixel(&p, x, y)[0] < 128)).collect() + }; + for (pair, (_, name)) in pairs.iter().enumerate() { + let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1)); + let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else { + panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len()); + }; + assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})"); + assert!( + (base_bottom + 1..bottom).any(|y| !accented.contains(&y)), + "{font} {name}: the mark is apart from the letter, a comma and not a cedilla" + ); + assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})"); + } + } +} diff --git a/crates/pdf/tests/upstream_text.rs b/crates/pdf/tests/upstream_text.rs new file mode 100644 index 00000000..471370cd --- /dev/null +++ b/crates/pdf/tests/upstream_text.rs @@ -0,0 +1,123 @@ +//! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0): +//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names. +#![cfg(feature = "interpret")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + font::Glyph, +}; +use hayro_cmap::BfString; +use kurbo::{Affine, BezPath, Rect}; + +/// The text of each glyph drawn, `None` where the font gives none. +#[derive(Default)] +struct TextDevice { + glyphs: Vec>, +} + +impl Device<'_> for TextDevice { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) { + self.glyphs.push(glyph.as_unicode().map(|text| match text { + BfString::Char(c) => c.to_string(), + BfString::String(s) => s, + })); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +/// The glyph texts of a one-page PDF whose font `/F1` is object 5; `extra` holds further objects. +fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n", + content.len() + ) + .into_bytes(); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj {font} endobj\n{extra}trailer << /Root 1 0 R >>\n%%EOF\n").as_bytes()); + let pdf = craft_pdf::open_slice(&pdf, None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let mut context = + Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = TextDevice::default(); + craft_pdf::interpret_page(page, &mut context, &mut device).unwrap(); + device.glyphs +} + +fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec { + let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding {encoding} >>"); + glyph_texts(&font, "", content).into_iter().map(|text| text.unwrap_or_default()).collect() +} + +/// PdfCraft ledger hayro-interpret (30): WinAnsiEncoding gives 0xAD and 0xA0 the glyph names +/// `hyphen` and `space`, and MacRomanEncoding gives 0xCA `space` (Annex D.2), so they read as the +/// ordinary hyphen and space rather than U+00AD and U+00A0. A `/Differences` entry naming +/// `sfthyphen` or `nbspace` keeps its glyph list value. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { + assert_eq!( + helvetica_texts("/WinAnsiEncoding", b"BT /F1 20 Tf 20 150 Td (a\xadb\xa0c) Tj ET"), + ["a", "-", "b", " ", "c"], + "WinAnsi 0xAD and 0xA0" + ); + assert_eq!(helvetica_texts("/MacRomanEncoding", b"BT /F1 20 Tf 20 150 Td (a\xcab) Tj ET"), ["a", " ", "b"], "MacRoman 0xCA"); + assert_eq!( + helvetica_texts( + "<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [88 /sfthyphen 89 /nbspace] >>", + b"BT /F1 20 Tf 20 150 Td (aXbYc) Tj ET" + ), + ["a", "\u{ad}", "b", "\u{a0}", "c"], + "a named glyph keeps its glyph list value" + ); +} + +/// PdfCraft ledger hayro-interpret P6 (#3169, #3170): ligature (`f_i`) and suffixed +/// (`hyphen.case`) names read by the glyph list specification's full mapping. A name that only +/// numbers the glyph (`C39`, `c251`), a ZapfDingbats name (`a12`) and a name with no meaning +/// left (`foo`) read as nothing. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn glyph_names_outside_the_glyph_list_still_read_as_text() { + let encoding = "<< /Type /Encoding /Differences [30 /f_i /f_l 2 /hyphen.case 39 /C39 97 /c251 174 /C174 \ + 88 /a12 /foo /uni00660069 61 /C39.sc 233 /C1] >>"; + let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (\x1e\x1f\x02'a\xaeXYZ=\xe9) Tj ET"); + assert_eq!(texts, ["fi", "fl", "-", "", "", "", "", "", "fi", "", ""]); +} + +/// PdfCraft ledger hayro-interpret P6: the `uni` and `u` forms take uppercase hex digits only. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn glyph_names_with_lowercase_hex_read_as_nothing() { + let encoding = "<< /Type /Encoding /Differences [65 /uni20ac0308 /uni20AC0308 /u1040c.alt /u1040C.alt /f_uni20ac /f_uni20AC] >>"; + let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (ABCDEF) Tj ET"); + assert_eq!(texts, ["", "\u{20ac}\u{308}", "", "\u{1040c}", "f", "f\u{20ac}"]); +} + +/// PdfCraft ledger hayro-interpret (44): `/ToUnicode /Identity-H` (a name, not a CMap stream) +/// reads two-byte codes as their Unicode values. Controls (a DEL and a C1 control) are not text. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn identity_to_unicode_names_give_text() { + for name in ["Identity-H", "Identity-V"] { + let font = format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /RobotoMono-Regular /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode /{name} >>" + ); + let cid = "6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /RobotoMono-Regular \ + /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity >> endobj\n"; + let texts = glyph_texts(&font, cid, b"BT /F1 12 Tf 20 200 Td <005A00FC0072006900630068002020AC007F0085> Tj ET"); + let texts: Vec = texts.into_iter().map(|text| text.unwrap_or_default()).collect(); + assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}"); + } +} diff --git a/scripts/test-pdf-backends.sh b/scripts/test-pdf-backends.sh index de8fae60..cc31a7fa 100644 --- a/scripts/test-pdf-backends.sh +++ b/scripts/test-pdf-backends.sh @@ -7,11 +7,11 @@ test_root=$(mktemp -d "${TMPDIR:-/tmp}/craft-pdf-backends.XXXXXX") trap 'rm -rf "$test_root"' EXIT export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$root/target}" patches=() -for backend in hayro hayro-interpret hayro-syntax hayro-jbig2; do +for backend in hayro hayro-interpret hayro-syntax hayro-jbig2 hayro-cmap hayro-postscript; do cp -R "$root/vendor/$backend" "$test_root/$backend" patches+=(--config "patch.crates-io.$backend.path=\"$test_root/$backend\"") done -for backend in hayro-syntax hayro-interpret hayro hayro-jbig2; do +for backend in hayro-syntax hayro-interpret hayro hayro-jbig2 hayro-cmap hayro-postscript; do cargo test --manifest-path "$test_root/$backend/Cargo.toml" --lib "${patches[@]}" done cargo test --manifest-path "$test_root/hayro-syntax/Cargo.toml" --doc "${patches[@]}" diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index d7444ba0..bada39a9 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -6,6 +6,25 @@ The upstream [patch ledger](https://github.com/storytold/pdfcraft/blob/7437bde37 describes the inherited patches. Source manifests record every transferred Git blob and SHA-256 alongside the local hash. The source manifests themselves are not self-hashed. +`hayro-cmap` 0.1.0 is transferred from [PdfCraft revision a2ec27b4775f53c40cad3456936d9406611e3a8d](https://github.com/storytold/pdfcraft/tree/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/hayro-cmap) +with its malformed-CMap patch (PdfCraft #3177): bad `bfchar` or `bfrange` entries, an +unresolvable `usecmap` and an unparseable tail are skipped instead of discarding the whole +CMap. Its tests `tests::bad_bfchar_entry_is_skipped` and `tests::unresolvable_usecmap_is_skipped` +come with it. One local patch: a binary `usecmap` counts against the 16-level nesting limit +as a text one does, so a self-referencing binary CMap stops instead of overflowing the stack. +Consuming workspaces need a `hayro-cmap` patch entry beside the other Hayro entries. + +`hayro-postscript` 0.1.0 is the crates.io package (upstream revision +a2354aebaf86f5ab13e5fd6b6b0521306488bf9d), the PostScript token scanner `hayro-cmap` uses +to parse the CMap streams PDFs embed. One local patch: the scanner, which does not parse +dictionaries yet, skips runs of `<<` and `>>` in a loop and returns the next object, instead +of recursing once per delimiter, so a long run of `<` or `>` bytes no longer overflows the +stack. Tests: `object::tests::long_open_dict_run_does_not_overflow`, +`object::tests::long_close_dict_run_does_not_overflow`, `object::tests::dict_delimiters_are_skipped` +and `object::tests::nested_dict_delimiters_are_skipped`. Its `SOURCE.json` records the +registry hashes. Consuming workspaces need a `hayro-postscript` patch entry beside the other +Hayro entries. + The shared snapshot adds immutable per-document ordinary decode limits, threaded through initial xrefs, object streams, fallback and repair. Existing constructors retain their previous default. Known-size image budgets and streaming content budgets remain distinct. @@ -58,6 +77,44 @@ Hostile-input bounds (CodeRabbit findings): - An xref-stream `/W` type field wider than one byte is read at its full width (big-endian) instead of only its first byte, and `/W[0]` over 8 bytes is refused like `/W[1]`. A dash array over 256 entries strokes solid. +- An xref-stream subsection running past object number `i32::MAX` is refused instead of + overflowing or turning negative. +- A CID font's `/W2` run that starts past CID 65535 is skipped as in `/W`, instead of + overflowing and discarding the whole table. +- CCITT images: `ccitt_size_ok` (inherited PdfCraft ledger item 2) allows at most 2^20 + columns and 2^28 pixels (columns × rows) before the decoder allocates. A `/Columns` past + `u32::MAX` is refused rather than wrapped to a small width. `/Rows` follows PdfCraft (17) + and (19): `/Rows 0` or a value below `/Height` decodes to the image height, and decoding + still stops where the data ends. + +Reader limits from the [PdfCraft revision a2ec27b4775f53c40cad3456936d9406611e3a8d ledger](https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/README.md), +by its item ids: hayro-interpret (31), its overflow part: the largest 32-bit Type 0 sample is +computed in 64 bits instead of overflowing `2_u32.pow(32)`. The flat sample array that (31) +also brings is a performance change and is not part of this set. hayro-syntax (7), (22) and +(25) are covered by the local bounds above: ASCII85 output stops at the decode limit, +object-stream indexes are parsed once with checked offsets, and direct objects nest at most +64 levels, stricter than PdfCraft's 128. Test: +`function::type0::tests::thirty_two_bit_samples_do_not_overflow`. + +Rendering fixes from the same [PdfCraft revision a2ec27b4775f53c40cad3456936d9406611e3a8d ledger](https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/README.md), +by its item ids, each marked `PdfCraft patch` in the source: hayro-syntax (17) and (19), CCITT +`/Rows 0` or fewer `/Rows` than `/Height` decode to the image height; (20), spaces or tabs +between `stream` and its line break; the `EI` extension of (4), `EI` followed by a delimiter +ends an inline image; (23), bytes above 0x7F after an `EI` candidate don't reject it; the +unledgered #3565 fix, a doubled sign (`--280`) is one number in both read and skip, beside the +local zero-tolerance sign tokens; and the `base_dimensions` one-point floor after `/UserUnit`. +Their unit tests come with them; the CCITT raster tests are adapted in `tests/upstream_raster.rs`. +Text fixes from the same ledger in hayro-interpret: (30), the Annex D.2 alias codes (WinAnsi +0xA0 and 0xAD, MacRoman 0xCA) read as an ordinary space and hyphen unless `/Differences` names +them; (44), a `/ToUnicode /Identity-H` or `/Identity-V` name reads two-byte codes as Unicode; +(P6), glyph names outside the Adobe Glyph List (`f_i`, `hyphen.case`, `uniXXXXXXXX`) read by +the glyph list specification's full mapping; (P8), a substitute without a spacing character +draws its ordinary space; (P2), a Type 3 glyph's `/FontBBox` is taken to device space before it +bounds the glyph's clip and shadings; and the extension of (26), comma-below letters (ș ț ķ ļ ņ +ŗ Ģ) in the standard-font stand-ins are built from the font's comma. The text contracts are +adapted in `tests/upstream_text.rs` and the Type 3 and comma-below raster tests in +`tests/upstream_raster.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not +ported. This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion, floating component precision and codec versions, rather than claiming full 0.8 diff --git a/vendor/hayro-cmap/Cargo.lock b/vendor/hayro-cmap/Cargo.lock new file mode 100644 index 00000000..b19a6c70 --- /dev/null +++ b/vendor/hayro-cmap/Cargo.lock @@ -0,0 +1,53 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "hayro-cmap" +version = "0.1.0" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-postscript" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" diff --git a/vendor/hayro-cmap/Cargo.toml b/vendor/hayro-cmap/Cargo.toml new file mode 100644 index 00000000..3818d177 --- /dev/null +++ b/vendor/hayro-cmap/Cargo.toml @@ -0,0 +1,87 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "hayro-cmap" +version = "0.1.0" +authors = ["Laurenz Stampfl "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A parser for CMap files." +readme = "README.md" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/LaurenzV/hayro" +resolver = "2" + +[features] +default = ["embed-cmaps"] +embed-cmaps = [ + "std", + "dep:brotli", +] +std = [] + +[lib] +name = "hayro_cmap" +path = "src/lib.rs" + +[dependencies.brotli] +version = "8" +features = [ + "std", + "std", +] +optional = true +default-features = false + +[dependencies.hayro-postscript] +version = "0.1.0" +default-features = false + +[lints.clippy] +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/hayro-cmap/Cargo.toml.orig b/vendor/hayro-cmap/Cargo.toml.orig new file mode 100644 index 00000000..59f5c99d --- /dev/null +++ b/vendor/hayro-cmap/Cargo.toml.orig @@ -0,0 +1,21 @@ +[package] +name = "hayro-cmap" +version = "0.1.0" +description = "A parser for CMap files." +authors = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[features] +default = ["embed-cmaps"] +std = [] +# Embed the 61 predefined cmaps into the binary. Adds about ~250KB of brotli-encoded data. +embed-cmaps = ["std", "dep:brotli"] + +[dependencies] +hayro-postscript = { workspace = true } +brotli = { workspace = true, optional = true, features = ["std"] } + +[lints] +workspace = true diff --git a/vendor/hayro-cmap/LICENSE-APACHE b/vendor/hayro-cmap/LICENSE-APACHE new file mode 100644 index 00000000..4947287f --- /dev/null +++ b/vendor/hayro-cmap/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/vendor/hayro-cmap/LICENSE-MIT b/vendor/hayro-cmap/LICENSE-MIT new file mode 100644 index 00000000..ae3742de --- /dev/null +++ b/vendor/hayro-cmap/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/hayro-cmap/README.md b/vendor/hayro-cmap/README.md new file mode 100644 index 00000000..bc93f93d --- /dev/null +++ b/vendor/hayro-cmap/README.md @@ -0,0 +1,25 @@ +# hayro-cmap + +[![Crates.io](https://img.shields.io/crates/v/hayro-cmap.svg)](https://crates.io/crates/hayro-cmap) +[![Documentation](https://docs.rs/hayro-cmap/badge.svg)](https://docs.rs/hayro-cmap) + + + +A parser for cmap files, as they are found in PDFs. + +This crate provides a parser for CMap files and allows you to +- Map character codes from text-showing operators to CID identifiers. +- Map CIDs to Unicode characters or strings. + +### Safety +This crate forbids unsafe code via a crate-level attribute. + + + +## License +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/vendor/hayro-cmap/SOURCE.json b/vendor/hayro-cmap/SOURCE.json new file mode 100644 index 00000000..2c72f523 --- /dev/null +++ b/vendor/hayro-cmap/SOURCE.json @@ -0,0 +1,98 @@ +{ + "crate": "hayro-cmap", + "source_repository": "https://github.com/storytold/pdfcraft", + "source_revision": "a2ec27b4775f53c40cad3456936d9406611e3a8d", + "source_directory": "vendor/hayro-cmap", + "files": [ + { + "path": "Cargo.lock", + "source_sha256": "b0ffb94a754c7254ecbead6649fa7877b6875531f9af8c62f7c28e1b11c5a391", + "source_git_blob": "b19a6c70fe633320e822a370fa242f32b7e0f914", + "sha256": "b0ffb94a754c7254ecbead6649fa7877b6875531f9af8c62f7c28e1b11c5a391" + }, + { + "path": "Cargo.toml", + "source_sha256": "7066550c610db3cb13df3c955f5737dccc3d9d24b6b680e9f633ecfadb82bf86", + "source_git_blob": "3818d177b6dcc5c0d754bc3ecf1a3422476c7e2f", + "sha256": "7066550c610db3cb13df3c955f5737dccc3d9d24b6b680e9f633ecfadb82bf86" + }, + { + "path": "Cargo.toml.orig", + "source_sha256": "9bcaf09fca4f5880318ba762cf07d5d269c458bb06f0b6409ae0879b2e564be3", + "source_git_blob": "59f5c99d3f5082e9f5064055da016aed0d4fcf35", + "sha256": "9bcaf09fca4f5880318ba762cf07d5d269c458bb06f0b6409ae0879b2e564be3" + }, + { + "path": "LICENSE-APACHE", + "source_sha256": "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9", + "source_git_blob": "4947287f7b5ccb5d1e8b7b2d3aa5d89f322c160d", + "sha256": "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9" + }, + { + "path": "LICENSE-MIT", + "source_sha256": "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac", + "source_git_blob": "ae3742de4e9ccb463cb46f05fe3ca0ec23fbb0d2", + "sha256": "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac" + }, + { + "path": "README.md", + "source_sha256": "167f3ef1e830520bcc45359a21e513069e75a42393722accca9f56822f1dcc82", + "source_git_blob": "bc93f93daa6b750afd709e2bcdfa2b2518b6e6a5", + "sha256": "167f3ef1e830520bcc45359a21e513069e75a42393722accca9f56822f1dcc82" + }, + { + "path": "assets/LICENSE.txt", + "source_sha256": "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29", + "source_git_blob": "2552cc990c12f85cdc50ef3c5185061a8c64e326", + "sha256": "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" + }, + { + "path": "assets/README.md", + "source_sha256": "61a2206d48aa4bac7dfc39416b0526fd994d0173897666f231f5c370cc06f646", + "source_git_blob": "4cedb1ff6d3c6d0b44e5cf20f749fb470bc8de01", + "sha256": "61a2206d48aa4bac7dfc39416b0526fd994d0173897666f231f5c370cc06f646" + }, + { + "path": "assets/cmaps.brotli", + "source_sha256": "ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46", + "source_git_blob": "ba74bd32fc71bb274e3e7e5c36bafaf89b9d6815", + "sha256": "ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46" + }, + { + "path": "src/bcmap/embedded.rs", + "source_sha256": "c1e18cf9fa5c413fe87bbc69394f2b8f0a7f6b5cda30b926665d0596ea79691b", + "source_git_blob": "0f5c241a0e278da6e3eefe72fe3925ce7e177f57", + "sha256": "c1e18cf9fa5c413fe87bbc69394f2b8f0a7f6b5cda30b926665d0596ea79691b" + }, + { + "path": "src/bcmap/huffman.rs", + "source_sha256": "9b1f4ba036f8b6e9011b2fb9c1d80f6b34ba4c1be3c101b9678a8b49a8d352ea", + "source_git_blob": "b6e8e9ab5d9fcdf831ceedaa7f620bd9d5e7ac8c", + "sha256": "9b1f4ba036f8b6e9011b2fb9c1d80f6b34ba4c1be3c101b9678a8b49a8d352ea" + }, + { + "path": "src/bcmap/mod.rs", + "source_sha256": "38dafbd8fcb5ca6f647d70d02cdb5af532ea7f2a3ac3f7ac8d1dea23abc93058", + "source_git_blob": "997bc00d85db1504c63cf56e3cb157410ebdb0bb", + "sha256": "3d0ea5e34bc92c40a250923ecb71b48571100bba5cc4664ea2c46d51086fe6be" + }, + { + "path": "src/bcmap/reader.rs", + "source_sha256": "343b20170f8445258fb564f4f5387f66df804920d73886981f0db8829562d1cf", + "source_git_blob": "25d6639104148a9123044c2c092a9bee5c889fd3", + "sha256": "343b20170f8445258fb564f4f5387f66df804920d73886981f0db8829562d1cf" + }, + { + "path": "src/lib.rs", + "source_sha256": "d666b3d8e4d37fc06c299a60ab9748ae17badcea271ff2cf864dcee76e27648c", + "source_git_blob": "fb42ff37ec9ccece66d33d1888bd25963cbf1171", + "sha256": "d6b9c6fa8de00942dd4cdfbd43578d3e53f8a3a6cf1e3563b345a9f6f35bc5e8" + }, + { + "path": "src/parse.rs", + "source_sha256": "1999127f05b59bdd55e2a7349dafc0b2213238201da37f7c35cd698996f2dc6b", + "source_git_blob": "a936209275ffca797912dec052dc03ddb0d2dce4", + "sha256": "1999127f05b59bdd55e2a7349dafc0b2213238201da37f7c35cd698996f2dc6b" + } + ] +} diff --git a/vendor/hayro-cmap/assets/LICENSE.txt b/vendor/hayro-cmap/assets/LICENSE.txt new file mode 100644 index 00000000..2552cc99 --- /dev/null +++ b/vendor/hayro-cmap/assets/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/hayro-cmap/assets/README.md b/vendor/hayro-cmap/assets/README.md new file mode 100644 index 00000000..4cedb1ff --- /dev/null +++ b/vendor/hayro-cmap/assets/README.md @@ -0,0 +1,2 @@ +See https://github.com/LaurenzV/cmap-resources/tree/hayro for information on how this blob was generated and how +it can be parsed. \ No newline at end of file diff --git a/vendor/hayro-cmap/assets/cmaps.brotli b/vendor/hayro-cmap/assets/cmaps.brotli new file mode 100644 index 00000000..ba74bd32 Binary files /dev/null and b/vendor/hayro-cmap/assets/cmaps.brotli differ diff --git a/vendor/hayro-cmap/src/bcmap/embedded.rs b/vendor/hayro-cmap/src/bcmap/embedded.rs new file mode 100644 index 00000000..0f5c241a --- /dev/null +++ b/vendor/hayro-cmap/src/bcmap/embedded.rs @@ -0,0 +1,129 @@ +use std::ops::Range; +use std::sync::LazyLock; + +use super::huffman::{self, HuffmanTable}; +use super::reader::Reader; +use crate::CMapName; + +pub(super) static BUNDLE: LazyLock = LazyLock::new(|| { + // We already know the bundle is valid, so we can skip validation and just + // unwrap everywhere. + + let compressed = include_bytes!("../../assets/cmaps.brotli"); + let mut decompressed = Vec::new(); + let mut reader = compressed.as_slice(); + + brotli::BrotliDecompress(&mut reader, &mut decompressed) + .ok() + .unwrap(); + + let mut reader = Reader::new(&decompressed); + let huff_size = reader.read_u32().unwrap() as usize; + let huff_data = reader.read_bytes(huff_size).unwrap(); + let (delta_table, count_table) = huffman::decode_tables(huff_data).unwrap(); + + let mut entries = Vec::new(); + + while !reader.at_end() { + let start = reader.position(); + + // Skip file magic and version. + reader.read_bytes(6).unwrap(); + let file_len = reader.read_u32().unwrap() as usize; + + reader.read_bytes(file_len - 10).unwrap(); + entries.push(start..start + file_len); + } + + Bundle { + data: decompressed, + delta_table, + count_table, + entries, + } +}); + +pub(super) struct Bundle { + data: Vec, + pub(super) delta_table: HuffmanTable, + pub(super) count_table: HuffmanTable, + entries: Vec>, +} + +/// Load the data for a cmap file, by name. +pub fn load_embedded(name: CMapName<'_>) -> Option<&'static [u8]> { + // Get the index of the font of the cmap in the bundle. They are sorted + // alphabetically. + let idx = match name { + CMapName::N83pvRksjH => 0, + CMapName::N90msRksjH => 1, + CMapName::N90msRksjV => 2, + CMapName::N90mspRksjH => 3, + CMapName::N90mspRksjV => 4, + CMapName::N90pvRksjH => 5, + CMapName::AddRksjH => 6, + CMapName::AddRksjV => 7, + CMapName::AdobeCns1Ucs2 => 8, + CMapName::AdobeGb1Ucs2 => 9, + CMapName::AdobeJapan1Ucs2 => 10, + CMapName::AdobeKorea1Ucs2 => 11, + CMapName::B5pcH => 12, + CMapName::B5pcV => 13, + CMapName::CnsEucH => 14, + CMapName::CnsEucV => 15, + CMapName::ETenB5H => 16, + CMapName::ETenB5V => 17, + CMapName::ETenmsB5H => 18, + CMapName::ETenmsB5V => 19, + CMapName::EucH => 20, + CMapName::EucV => 21, + CMapName::ExtRksjH => 22, + CMapName::ExtRksjV => 23, + CMapName::GbEucH => 24, + CMapName::GbEucV => 25, + CMapName::GbkEucH => 26, + CMapName::GbkEucV => 27, + CMapName::Gbk2kH => 28, + CMapName::Gbk2kV => 29, + CMapName::GbkpEucH => 30, + CMapName::GbkpEucV => 31, + CMapName::GbpcEucH => 32, + CMapName::GbpcEucV => 33, + CMapName::H => 34, + CMapName::HKscsB5H => 35, + CMapName::HKscsB5V => 36, + CMapName::IdentityH => 37, + CMapName::IdentityV => 38, + CMapName::KscEucH => 39, + CMapName::KscEucV => 40, + CMapName::KscmsUhcH => 41, + CMapName::KscmsUhcHwH => 42, + CMapName::KscmsUhcHwV => 43, + CMapName::KscmsUhcV => 44, + CMapName::KscpcEucH => 45, + CMapName::UniCnsUcs2H => 46, + CMapName::UniCnsUcs2V => 47, + CMapName::UniCnsUtf16H => 48, + CMapName::UniCnsUtf16V => 49, + CMapName::UniGbUcs2H => 50, + CMapName::UniGbUcs2V => 51, + CMapName::UniGbUtf16H => 52, + CMapName::UniGbUtf16V => 53, + CMapName::UniJisUcs2H => 54, + CMapName::UniJisUcs2HwH => 55, + CMapName::UniJisUcs2HwV => 56, + CMapName::UniJisUcs2V => 57, + CMapName::UniJisUtf16H => 58, + CMapName::UniJisUtf16V => 59, + CMapName::UniKsUcs2H => 60, + CMapName::UniKsUcs2V => 61, + CMapName::UniKsUtf16H => 62, + CMapName::UniKsUtf16V => 63, + CMapName::V => 64, + CMapName::Custom(_) => return None, + }; + + let range = BUNDLE.entries.get(idx)?; + + Some(&BUNDLE.data[range.clone()]) +} diff --git a/vendor/hayro-cmap/src/bcmap/huffman.rs b/vendor/hayro-cmap/src/bcmap/huffman.rs new file mode 100644 index 00000000..b6e8e9ab --- /dev/null +++ b/vendor/hayro-cmap/src/bcmap/huffman.rs @@ -0,0 +1,180 @@ +use alloc::vec; +use alloc::vec::Vec; +use core::num::NonZeroU32; + +use super::reader::Reader; + +#[derive(Debug, Clone, Copy)] +pub(super) enum HuffmanNode { + /// Intermediate node with optional children. + Intermediate { + /// Index of the zero-child. + zero: Option, + /// Index of the one-child. + one: Option, + }, + /// Leaf node holding a decoded symbol value. + Leaf(u32), +} + +/// A Huffman table built from canonical codes. +#[derive(Debug)] +pub(super) struct HuffmanTable { + nodes: Vec, +} + +impl HuffmanTable { + /// Decode a single symbol from the bit reader. + pub(super) fn decode(&self, reader: &mut Reader<'_>) -> Option { + let mut idx = 0_u32; + + loop { + match self.nodes[idx as usize] { + HuffmanNode::Intermediate { zero, one } => { + let bit = reader.read_bit()?; + let child = if bit == 0 { zero } else { one }; + idx = child?.get(); + } + HuffmanNode::Leaf(symbol) => return Some(symbol), + } + } + } + + fn insert_code( + nodes: &mut Vec, + node_index: u32, + code: u32, + length: u8, + symbol: u32, + ) { + if length == 0 { + nodes[node_index as usize] = HuffmanNode::Leaf(symbol); + + return; + } + + let bit = (code >> (length - 1)) & 1; + let remaining = code & ((1 << (length - 1)) - 1); + + let child_index = match &nodes[node_index as usize] { + HuffmanNode::Intermediate { zero, one } => { + let existing = if bit == 0 { *zero } else { *one }; + match existing { + Some(idx) => idx, + None => { + let new_idx = NonZeroU32::new(nodes.len() as u32).unwrap(); + nodes.push(HuffmanNode::Intermediate { + zero: None, + one: None, + }); + + match &mut nodes[node_index as usize] { + HuffmanNode::Intermediate { zero, one } => { + if bit == 0 { + *zero = Some(new_idx); + } else { + *one = Some(new_idx); + } + } + _ => unreachable!(), + } + new_idx + } + } + } + _ => return, + }; + + Self::insert_code(nodes, child_index.get(), remaining, length - 1, symbol); + } + + fn build(code_lengths: &[u8], symbols: &[u32]) -> Self { + debug_assert_eq!(code_lengths.len(), symbols.len()); + + if symbols.is_empty() { + return Self { + nodes: vec![HuffmanNode::Intermediate { + zero: None, + one: None, + }], + }; + } + + let max_length = *code_lengths.iter().max().unwrap_or(&0) as usize; + + let mut codes = vec![0_u32; symbols.len()]; + let mut code = 0_u32; + + for length in 1..=max_length { + for (i, &cl) in code_lengths.iter().enumerate() { + if cl as usize == length { + codes[i] = code; + code += 1; + } + } + code <<= 1; + } + + let mut nodes = vec![HuffmanNode::Intermediate { + zero: None, + one: None, + }]; + + for (i, &symbol) in symbols.iter().enumerate() { + if code_lengths[i] == 0 { + continue; + } + + Self::insert_code(&mut nodes, 0, codes[i], code_lengths[i], symbol); + } + + Self { nodes } + } +} + +pub(super) fn decode_tables(data: &[u8]) -> Option<(HuffmanTable, HuffmanTable)> { + let mut reader = Reader::new(data); + let delta = decode_single_table(&mut reader, |r| r.read_u32())?; + let count = decode_single_table(&mut reader, |r| r.read_u8().map(u32::from))?; + + Some((delta, count)) +} + +fn decode_single_table( + reader: &mut Reader<'_>, + read_sym: fn(&mut Reader<'_>) -> Option, +) -> Option { + let n_symbols = reader.read_u16()? as usize; + + if n_symbols == 0 { + let _max_len = reader.read_u8()?; + + return Some(HuffmanTable { + nodes: vec![HuffmanNode::Intermediate { + zero: None, + one: None, + }], + }); + } + + let max_code_length = reader.read_u8()? as usize; + + let mut counts = Vec::with_capacity(max_code_length); + for _ in 0..max_code_length { + counts.push(reader.read_u16()?); + } + + let mut code_lengths = Vec::with_capacity(n_symbols); + let mut symbols = Vec::with_capacity(n_symbols); + + for (length_idx, &count) in counts.iter().enumerate() { + let code_len = (length_idx + 1) as u8; + + for _ in 0..count { + symbols.push(read_sym(reader)?); + code_lengths.push(code_len); + } + } + + Some(HuffmanTable::build(&code_lengths, &symbols)) +} diff --git a/vendor/hayro-cmap/src/bcmap/mod.rs b/vendor/hayro-cmap/src/bcmap/mod.rs new file mode 100644 index 00000000..e20c4590 --- /dev/null +++ b/vendor/hayro-cmap/src/bcmap/mod.rs @@ -0,0 +1,419 @@ +//! Inspired by pdf.js, we use a custom binary format to optionally embed +//! the required cmaps in a space-efficient manner. However, we don't use +//! their format description but have a custom one. For more information, +//! see . + +pub(crate) mod embedded; +pub(crate) mod huffman; +pub(crate) mod reader; + +use alloc::boxed::Box; +use alloc::vec::Vec; + +pub use embedded::load_embedded; + +use crate::bcmap::embedded::BUNDLE; +use crate::{ + BfRange, CMap, CMapName, CharacterCollection, CidFamily, CidRange, CodespaceRange, Metadata, + PartitionedRanges, Range, WritingMode, parse, +}; +use huffman::HuffmanTable; +use reader::Reader; + +const BCMAP_MAGIC: &[u8] = b"bcmap"; +const BCMAP_VERSION: u8 = 0x01; +const BCMAP_FILE_HEADER_SIZE: usize = 10; +const SEG_HEADER_SIZE: usize = 5; + +const SEGMENT_RANGE_1B: u8 = 0x01; +const SEGMENT_SINGLE_1B: u8 = 0x02; +const SEGMENT_RANGE_2B: u8 = 0x03; +const SEGMENT_SINGLE_2B: u8 = 0x04; +const SEGMENT_RANGE_3B: u8 = 0x05; +const SEGMENT_SINGLE_3B: u8 = 0x06; +const SEGMENT_RANGE_4B: u8 = 0x07; +const SEGMENT_SINGLE_4B: u8 = 0x08; +const SEGMENT_USECMAP: u8 = 0x09; +const SEGMENT_NOTDEF: u8 = 0x0A; +const SEGMENT_WMODE: u8 = 0x0B; +const SEGMENT_CODESPACE: u8 = 0x0C; +const SEGMENT_NAME: u8 = 0x0D; +const SEGMENT_CID_SYSTEM_INFO: u8 = 0x0E; +const SEGMENT_BF_RANGE_VARIABLE: u8 = 0x0F; +const SEGMENT_BF_SINGLE_VARIABLE: u8 = 0x10; +const SEGMENT_BF_SINGLE_1U: u8 = 0x11; +const SEGMENT_BF_SINGLE_2U: u8 = 0x12; +const SEGMENT_BF_SINGLE_3U: u8 = 0x13; +const SEGMENT_BF_SINGLE_4U: u8 = 0x14; +const SEGMENT_BF_RANGE_1U: u8 = 0x15; +const SEGMENT_BF_RANGE_2U: u8 = 0x16; + +pub(crate) fn parse<'a>( + data: &[u8], + get_cmap: impl Fn(CMapName<'_>) -> Option<&'a [u8]> + Clone + 'a, + depth: u32, +) -> Option { + // While in theory we can assume that all binary cmaps are valid, it can + // of course happen that an invalid one has been passed from outside, so + // we still need to do proper validation. + + if data.get(..5)? != BCMAP_MAGIC || *data.get(5)? != BCMAP_VERSION { + return None; + } + + let mut reader = Reader::new(data); + reader.read_bytes(6)?; // Skip magic + version. + + let file_len = reader.read_u32()? as usize; + + let delta_table = &BUNDLE.delta_table; + let count_table = &BUNDLE.count_table; + + let mut cmap_name = None; + let mut character_collection = None; + let mut writing_mode = None; + let mut base: Option> = None; + let mut _codespace_ranges = Vec::new(); + let mut cid_ranges = PartitionedRanges::new(); + let mut notdef_ranges = PartitionedRanges::new(); + let mut bf_entries = Vec::new(); + + // Start parsing all segments of the file. + let mut reader = Reader::new(data.get(BCMAP_FILE_HEADER_SIZE..file_len)?); + + while !reader.at_end() { + let seg_type = reader.read_u8()?; + let seg_len = reader.read_u32()? as usize; + + let payload = reader.read_bytes(seg_len.checked_sub(SEG_HEADER_SIZE)?)?; + + match seg_type { + SEGMENT_NAME => { + cmap_name = Some(Vec::from(payload)); + } + SEGMENT_CID_SYSTEM_INFO => { + // Format: Each string is 0-terminated. + let mut r = Reader::new(payload); + let registry = Vec::from(r.eat_until(|b| b == 0)); + r.read_u8()?; + + let ordering = Vec::from(r.eat_until(|b| b == 0)); + r.read_u8()?; + let supplement = r.read_u16()? as i32; + + character_collection = Some(CharacterCollection { + family: CidFamily::from_registry_ordering(®istry, &ordering), + supplement, + }); + } + SEGMENT_USECMAP => { + let base_data = get_cmap(CMapName::from_bytes(payload))?; + + // PdfCraft patch: a binary `usecmap` counts against the nesting limit as a text + // one does; passing the same depth let a self-referencing CMap recurse until the + // stack overflowed. + base = Some(Box::new(parse::parse_inner( + base_data, + get_cmap.clone(), + depth + 1, + )?)); + } + SEGMENT_WMODE => { + writing_mode = match payload.first()? { + 0 => Some(WritingMode::Horizontal), + 1 => Some(WritingMode::Vertical), + _ => None, + }; + } + SEGMENT_CODESPACE => { + parse_codespace(payload, &mut _codespace_ranges)?; + } + SEGMENT_NOTDEF => { + parse_notdef(payload, &mut notdef_ranges)?; + } + SEGMENT_RANGE_1B | SEGMENT_RANGE_2B | SEGMENT_RANGE_3B | SEGMENT_RANGE_4B => { + let bw = seg_type.div_ceil(2) as usize; + parse_cid_segment(payload, &mut cid_ranges, bw, delta_table, Some(count_table))?; + } + SEGMENT_SINGLE_1B | SEGMENT_SINGLE_2B | SEGMENT_SINGLE_3B | SEGMENT_SINGLE_4B => { + let bw = seg_type.div_ceil(2) as usize; + parse_cid_segment(payload, &mut cid_ranges, bw, delta_table, None)?; + } + SEGMENT_BF_RANGE_VARIABLE => { + parse_bf_segment( + payload, + &mut bf_entries, + delta_table, + Some(count_table), + None, + )?; + } + SEGMENT_BF_SINGLE_VARIABLE => { + parse_bf_segment(payload, &mut bf_entries, delta_table, None, None)?; + } + SEGMENT_BF_RANGE_1U => { + parse_bf_segment( + payload, + &mut bf_entries, + delta_table, + Some(count_table), + Some(1), + )?; + } + SEGMENT_BF_RANGE_2U => { + parse_bf_segment( + payload, + &mut bf_entries, + delta_table, + Some(count_table), + Some(2), + )?; + } + SEGMENT_BF_SINGLE_1U => { + parse_bf_segment(payload, &mut bf_entries, delta_table, None, Some(1))?; + } + SEGMENT_BF_SINGLE_2U => { + parse_bf_segment(payload, &mut bf_entries, delta_table, None, Some(2))?; + } + SEGMENT_BF_SINGLE_3U => { + parse_bf_segment(payload, &mut bf_entries, delta_table, None, Some(3))?; + } + SEGMENT_BF_SINGLE_4U => { + parse_bf_segment(payload, &mut bf_entries, delta_table, None, Some(4))?; + } + _ => { + return None; + } + } + } + + cid_ranges.sort(); + notdef_ranges.sort(); + bf_entries.sort_by(|a, b| a.range.start.cmp(&b.range.start)); + + Some(CMap { + metadata: Metadata { + character_collection, + name: cmap_name, + writing_mode, + }, + _codespace_ranges, + cid_ranges, + notdef_ranges, + bf_entries, + base, + }) +} + +fn parse_codespace(payload: &[u8], ranges: &mut Vec) -> Option<()> { + let mut r = Reader::new(payload); + let n_ranges = r.read_u8()? as usize; + + for _ in 0..n_ranges { + let bw = r.read_u8()? as usize; + let low = r.read_n_bytes(bw)?; + let high = r.read_n_bytes(bw)?; + + ranges.push(CodespaceRange { + number_bytes: bw as u8, + low, + high, + }); + } + + Some(()) +} + +fn parse_notdef(payload: &[u8], ranges: &mut PartitionedRanges) -> Option<()> { + let mut r = Reader::new(payload); + let bw = r.read_u8()? as usize; + let n_entries = r.read_u16()? as usize; + + for _ in 0..n_entries { + let start = r.read_n_bytes(bw)?; + let end = r.read_n_bytes(bw)?; + let cid = r.read_u16()? as u32; + + ranges.push( + bw, + CidRange { + range: Range { start, end }, + cid_start: cid, + }, + ); + } + + Some(()) +} + +fn parse_cid_segment( + payload: &[u8], + ranges: &mut PartitionedRanges, + byte_width: usize, + delta_table: &HuffmanTable, + count_table: Option<&HuffmanTable>, +) -> Option<()> { + let mut r = Reader::new(payload); + let n_entries = r.read_u16()? as usize; + + if n_entries == 0 { + return Some(()); + } + + // There are two types of CID segments, all of which are stored in a columnar + // fastion in the file: + // For the first type, we have a simple mapping from a single code to a single + // CID. In this case, the data stream first contains all codes using delta-coding, + // encoded using huffman coding. This is followed by all CIDs, stored as + // u16 (either as a raw CID, or as `0` in case the CID is +1 the previous CID). + // + // For the second type, we have a range of consecutive codes, which map + // to a range of consecutive CIDs. In this case, we have an additional "count" + // column that stores how many codes are mapped consecutively. These are also + // encoded using huffman coding. + + // Read deltas for the start code points. + let delta_len = r.read_u32()? as usize; + let delta_data = r.read_bytes(delta_len)?; + + let mut delta_reader = Reader::new(delta_data); + let mut deltas = Vec::with_capacity(n_entries); + + for _ in 0..n_entries { + deltas.push(delta_table.decode(&mut delta_reader)?); + } + + // Read the counts for each consecutive code range. + let mut counts = Vec::new(); + if let Some(ct) = count_table { + let count_len = r.read_u32()? as usize; + let count_data = r.read_bytes(count_len)?; + + let mut count_reader = Reader::new(count_data); + counts.reserve(n_entries); + for _ in 0..n_entries { + counts.push(ct.decode(&mut count_reader)?); + } + } + + // Read the CIDs and reconstruct the character codes that map to them. + let is_range = count_table.is_some(); + let mut prev_end: Option = None; + let mut prev_cid: Option = None; + let mut prev_range_len: u32 = 0; + + for i in 0..n_entries { + let raw_cid = r.read_u16()?; + + // Note that start deltas and counts are encoded minus one, so we + // need to add one when reconstructing them. + + // Reconstruct start code. + let start = if let Some(pe) = prev_end { + pe + 1 + deltas[i] + } else { + deltas[i] + }; + + // Reconstruct end code. + let end = if is_range { + start + counts[i] + 1 + } else { + start + }; + + // CID 0 means it's consecutive to the last seen CID, plus 1. + let cid = if raw_cid == 0 { + if let Some(pc) = prev_cid { + pc + prev_range_len + 1 + } else { + 0 + } + } else { + raw_cid as u32 + }; + + ranges.push( + byte_width, + CidRange { + range: Range { start, end }, + cid_start: cid, + }, + ); + + prev_end = Some(end); + prev_cid = Some(cid); + prev_range_len = end - start; + } + + Some(()) +} + +fn parse_bf_segment( + payload: &[u8], + entries: &mut Vec, + delta_table: &HuffmanTable, + count_table: Option<&HuffmanTable>, + fixed_units: Option, +) -> Option<()> { + let mut r = Reader::new(payload); + let n_entries = r.read_u16()? as usize; + + let delta_len = r.read_u32()? as usize; + let delta_data = r.read_bytes(delta_len)?; + + let mut delta_reader = Reader::new(delta_data); + let mut deltas = Vec::with_capacity(n_entries); + for _ in 0..n_entries { + deltas.push(delta_table.decode(&mut delta_reader)?); + } + + // Read counts for range segments. + let mut counts = Vec::new(); + if let Some(ct) = count_table { + let count_len = r.read_u32()? as usize; + let count_data = r.read_bytes(count_len)?; + + let mut count_reader = Reader::new(count_data); + counts.reserve(n_entries); + for _ in 0..n_entries { + counts.push(ct.decode(&mut count_reader)?); + } + } + + let is_range = count_table.is_some(); + let mut prev_end: Option = None; + + for i in 0..n_entries { + // Fixed-width segments encode destinations without a length prefix. + let n_units = match fixed_units { + Some(nu) => nu, + None => r.read_u8()? as usize, + }; + let mut dst_base = Vec::with_capacity(n_units); + for _ in 0..n_units { + dst_base.push(r.read_u16()?); + } + + let start = if let Some(pe) = prev_end { + pe + 1 + deltas[i] + } else { + deltas[i] + }; + + let end = if is_range { + start + counts[i] + 1 + } else { + start + }; + + entries.push(BfRange { + range: Range { start, end }, + dst_base, + }); + + prev_end = Some(end); + } + + Some(()) +} diff --git a/vendor/hayro-cmap/src/bcmap/reader.rs b/vendor/hayro-cmap/src/bcmap/reader.rs new file mode 100644 index 00000000..25d66391 --- /dev/null +++ b/vendor/hayro-cmap/src/bcmap/reader.rs @@ -0,0 +1,123 @@ +pub(super) struct Reader<'a> { + data: &'a [u8], + byte_pos: usize, + bit_pos: u8, +} + +impl<'a> Reader<'a> { + #[inline] + pub(super) fn new(data: &'a [u8]) -> Self { + Self { + data, + byte_pos: 0, + bit_pos: 0, + } + } + + #[inline] + pub(super) fn read_bit(&mut self) -> Option { + let byte = *self.data.get(self.byte_pos)?; + let bit = (byte >> (7 - self.bit_pos)) & 1; + + self.bit_pos += 1; + + if self.bit_pos == 8 { + self.bit_pos = 0; + self.byte_pos += 1; + } + + Some(bit) + } + + #[inline] + pub(super) fn read_u16(&mut self) -> Option { + debug_assert_eq!(self.bit_pos, 0, "read_u16 called at non-byte boundary"); + + let bytes: [u8; 2] = self + .data + .get(self.byte_pos..self.byte_pos + 2)? + .try_into() + .ok()?; + self.byte_pos += 2; + + Some(u16::from_be_bytes(bytes)) + } + + #[inline] + pub(super) fn read_u32(&mut self) -> Option { + debug_assert_eq!(self.bit_pos, 0, "read_u32 called at non-byte boundary"); + + let bytes: [u8; 4] = self + .data + .get(self.byte_pos..self.byte_pos + 4)? + .try_into() + .ok()?; + self.byte_pos += 4; + + Some(u32::from_be_bytes(bytes)) + } + + #[inline] + pub(super) fn read_u8(&mut self) -> Option { + debug_assert_eq!(self.bit_pos, 0, "read_u8 called at non-byte boundary"); + + let val = *self.data.get(self.byte_pos)?; + self.byte_pos += 1; + + Some(val) + } + + #[inline] + pub(super) fn read_n_bytes(&mut self, n: usize) -> Option { + debug_assert_eq!( + self.bit_pos, 0, + "read_n_bytes_be called at non-byte boundary" + ); + + match n { + 1 => Some(self.read_u8()? as u32), + 2 => Some(self.read_u16()? as u32), + 3 => { + let bytes = self.read_bytes(3)?; + Some((bytes[0] as u32) << 16 | (bytes[1] as u32) << 8 | bytes[2] as u32) + } + 4 => self.read_u32(), + _ => None, + } + } + + #[inline] + pub(super) fn read_bytes(&mut self, n: usize) -> Option<&'a [u8]> { + debug_assert_eq!(self.bit_pos, 0, "read_bytes called at non-byte boundary"); + + let bytes = self.data.get(self.byte_pos..self.byte_pos + n)?; + self.byte_pos += n; + + Some(bytes) + } + + #[inline] + pub(super) fn position(&self) -> usize { + self.byte_pos + } + + #[inline] + pub(super) fn at_end(&self) -> bool { + self.byte_pos >= self.data.len() + } + + #[inline] + pub(super) fn eat_until(&mut self, f: impl Fn(u8) -> bool) -> &'a [u8] { + debug_assert_eq!(self.bit_pos, 0, "eat_until called at non-byte boundary"); + + let start = self.byte_pos; + while let Some(&b) = self.data.get(self.byte_pos) { + if f(b) { + break; + } + self.byte_pos += 1; + } + + &self.data[start..self.byte_pos] + } +} diff --git a/vendor/hayro-cmap/src/lib.rs b/vendor/hayro-cmap/src/lib.rs new file mode 100644 index 00000000..2ef3c6d1 --- /dev/null +++ b/vendor/hayro-cmap/src/lib.rs @@ -0,0 +1,1710 @@ +/*! +A parser for cmap files, as they are found in PDFs. + +This crate provides a parser for cmap files and allows you to +- Map character codes from text-showing operators to CID identifiers. +- Map CIDs to Unicode characters or strings. + +## Safety +This crate forbids unsafe code via a crate-level attribute. +*/ + +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +extern crate alloc; + +#[cfg(feature = "embed-cmaps")] +mod bcmap; +mod parse; + +#[cfg(feature = "embed-cmaps")] +pub use bcmap::load_embedded; + +/// Look up an embedded binary cmap by name. +/// +/// Returns `None` when the `embed-cmaps` feature is not enabled. +#[cfg(not(feature = "embed-cmaps"))] +pub fn load_embedded(_name: CMapName<'_>) -> Option<&'static [u8]> { + None +} + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; + +/// A CID (Character Identifier). +pub type Cid = u32; + +/// The name of the cmap. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CMapName<'a> { + // ── Adobe-Japan1 (Japanese) ── Shift-JIS (RKSJ) encodings ── + /// `83pv-RKSJ-H` — Adobe-Japan1, Mac Shift-JIS (`KanjiTalk` 6), horizontal. + N83pvRksjH, + /// `90ms-RKSJ-H` — Adobe-Japan1, Windows Shift-JIS (code page 932), horizontal. + N90msRksjH, + /// `90ms-RKSJ-V` — Adobe-Japan1, Windows Shift-JIS (code page 932), vertical. + N90msRksjV, + /// `90msp-RKSJ-H` — Adobe-Japan1, Windows Shift-JIS with proportional Roman, horizontal. + N90mspRksjH, + /// `90msp-RKSJ-V` — Adobe-Japan1, Windows Shift-JIS with proportional Roman, vertical. + N90mspRksjV, + /// `90pv-RKSJ-H` — Adobe-Japan1, Mac Shift-JIS (`KanjiTalk` 7), horizontal. + N90pvRksjH, + /// `Add-RKSJ-H` — Adobe-Japan1, Fujitsu FMR Shift-JIS, horizontal. + AddRksjH, + /// `Add-RKSJ-V` — Adobe-Japan1, Fujitsu FMR Shift-JIS, vertical. + AddRksjV, + + // ── Adobe UCS2 (CID → Unicode) ── + // Note that these are not part of the predefined CMaps, but we use them + // so we can more easily map CIDs back to Unicode. + /// `Adobe-CNS1-UCS2` — Adobe-CNS1, CID-to-Unicode mapping. + AdobeCns1Ucs2, + /// `Adobe-GB1-UCS2` — Adobe-GB1, CID-to-Unicode mapping. + AdobeGb1Ucs2, + /// `Adobe-Japan1-UCS2` — Adobe-Japan1, CID-to-Unicode mapping. + AdobeJapan1Ucs2, + /// `Adobe-Korea1-UCS2` — Adobe-Korea1, CID-to-Unicode mapping. + AdobeKorea1Ucs2, + + // ── Adobe-CNS1 (Traditional Chinese) ── Big Five encodings ── + /// `B5pc-H` — Adobe-CNS1, Mac Big Five (`ETen` extensions), horizontal. + B5pcH, + /// `B5pc-V` — Adobe-CNS1, Mac Big Five (`ETen` extensions), vertical. + B5pcV, + /// `CNS-EUC-H` — Adobe-CNS1, CNS 11643 EUC encoding, horizontal. + CnsEucH, + /// `CNS-EUC-V` — Adobe-CNS1, CNS 11643 EUC encoding, vertical. + CnsEucV, + /// `ETen-B5-H` — Adobe-CNS1, `ETen` Big Five extensions, horizontal. + ETenB5H, + /// `ETen-B5-V` — Adobe-CNS1, `ETen` Big Five extensions, vertical. + ETenB5V, + /// `ETenms-B5-H` — Adobe-CNS1, `ETen` Big Five with Microsoft symbol extensions, horizontal. + ETenmsB5H, + /// `ETenms-B5-V` — Adobe-CNS1, `ETen` Big Five with Microsoft symbol extensions, vertical. + ETenmsB5V, + + // ── Adobe-Japan1 (Japanese) ── EUC and extended Shift-JIS encodings ── + /// `EUC-H` — Adobe-Japan1, JIS X 0208 EUC-JP encoding, horizontal. + EucH, + /// `EUC-V` — Adobe-Japan1, JIS X 0208 EUC-JP encoding, vertical. + EucV, + /// `Ext-RKSJ-H` — Adobe-Japan1, Shift-JIS with NEC/IBM extensions, horizontal. + ExtRksjH, + /// `Ext-RKSJ-V` — Adobe-Japan1, Shift-JIS with NEC/IBM extensions, vertical. + ExtRksjV, + + // ── Adobe-GB1 (Simplified Chinese) ── + /// `GB-EUC-H` — Adobe-GB1, GB 2312-80 EUC encoding, horizontal. + GbEucH, + /// `GB-EUC-V` — Adobe-GB1, GB 2312-80 EUC encoding, vertical. + GbEucV, + /// `GBK-EUC-H` — Adobe-GB1, GBK encoding (Microsoft code page 936), horizontal. + GbkEucH, + /// `GBK-EUC-V` — Adobe-GB1, GBK encoding (Microsoft code page 936), vertical. + GbkEucV, + /// `GBK2K-H` — Adobe-GB1, GB 18030-2000 encoding, horizontal. + Gbk2kH, + /// `GBK2K-V` — Adobe-GB1, GB 18030-2000 encoding, vertical. + Gbk2kV, + /// `GBKp-EUC-H` — Adobe-GB1, GBK with proportional Roman, horizontal. + GbkpEucH, + /// `GBKp-EUC-V` — Adobe-GB1, GBK with proportional Roman, vertical. + GbkpEucV, + /// `GBpc-EUC-H` — Adobe-GB1, Mac GB 2312 (simplified) EUC encoding, horizontal. + GbpcEucH, + /// `GBpc-EUC-V` — Adobe-GB1, Mac GB 2312 (simplified) EUC encoding, vertical. + GbpcEucV, + + // ── Adobe-Japan1 (Japanese) ── JIS encoding ── + /// `H` — Adobe-Japan1, JIS X 0208 row-cell encoding, horizontal. + H, + + // ── Adobe-CNS1 (Traditional Chinese) ── Hong Kong SCS ── + /// `HKscs-B5-H` — Adobe-CNS1, Hong Kong SCS (Big Five with HKSCS extensions), horizontal. + HKscsB5H, + /// `HKscs-B5-V` — Adobe-CNS1, Hong Kong SCS (Big Five with HKSCS extensions), vertical. + HKscsB5V, + + // ── Adobe-Identity ── + /// `Identity-H` — Adobe-Identity, two-byte identity mapping, horizontal. + /// Character codes map directly to CIDs (i.e. CID = character code). + IdentityH, + /// `Identity-V` — Adobe-Identity, two-byte identity mapping, vertical. + /// Character codes map directly to CIDs (i.e. CID = character code). + IdentityV, + + // ── Adobe-Korea1 (Korean) ── + /// `KSC-EUC-H` — Adobe-Korea1, KS X 1001:1992 EUC-KR encoding, horizontal. + KscEucH, + /// `KSC-EUC-V` — Adobe-Korea1, KS X 1001:1992 EUC-KR encoding, vertical. + KscEucV, + /// `KSCms-UHC-H` — Adobe-Korea1, Microsoft UHC (Unified Hangul Code, code page 949), horizontal. + KscmsUhcH, + /// `KSCms-UHC-HW-H` — Adobe-Korea1, Microsoft UHC with half-width Roman, horizontal. + KscmsUhcHwH, + /// `KSCms-UHC-HW-V` — Adobe-Korea1, Microsoft UHC with half-width Roman, vertical. + KscmsUhcHwV, + /// `KSCms-UHC-V` — Adobe-Korea1, Microsoft UHC (Unified Hangul Code, code page 949), vertical. + KscmsUhcV, + /// `KSCpc-EUC-H` — Adobe-Korea1, Mac KS X 1001:1992 EUC-KR encoding, horizontal. + KscpcEucH, + + // ── Adobe-CNS1 (Traditional Chinese) ── Unicode encodings ── + /// `UniCNS-UCS2-H` — Adobe-CNS1, Unicode UCS-2 encoding, horizontal. + UniCnsUcs2H, + /// `UniCNS-UCS2-V` — Adobe-CNS1, Unicode UCS-2 encoding, vertical. + UniCnsUcs2V, + /// `UniCNS-UTF16-H` — Adobe-CNS1, Unicode UTF-16 encoding, horizontal. + UniCnsUtf16H, + /// `UniCNS-UTF16-V` — Adobe-CNS1, Unicode UTF-16 encoding, vertical. + UniCnsUtf16V, + + // ── Adobe-GB1 (Simplified Chinese) ── Unicode encodings ── + /// `UniGB-UCS2-H` — Adobe-GB1, Unicode UCS-2 encoding, horizontal. + UniGbUcs2H, + /// `UniGB-UCS2-V` — Adobe-GB1, Unicode UCS-2 encoding, vertical. + UniGbUcs2V, + /// `UniGB-UTF16-H` — Adobe-GB1, Unicode UTF-16 encoding, horizontal. + UniGbUtf16H, + /// `UniGB-UTF16-V` — Adobe-GB1, Unicode UTF-16 encoding, vertical. + UniGbUtf16V, + + // ── Adobe-Japan1 (Japanese) ── Unicode encodings ── + /// `UniJIS-UCS2-H` — Adobe-Japan1, Unicode UCS-2 encoding, horizontal. + UniJisUcs2H, + /// `UniJIS-UCS2-HW-H` — Adobe-Japan1, Unicode UCS-2 with half-width Roman, horizontal. + UniJisUcs2HwH, + /// `UniJIS-UCS2-HW-V` — Adobe-Japan1, Unicode UCS-2 with half-width Roman, vertical. + UniJisUcs2HwV, + /// `UniJIS-UCS2-V` — Adobe-Japan1, Unicode UCS-2 encoding, vertical. + UniJisUcs2V, + /// `UniJIS-UTF16-H` — Adobe-Japan1, Unicode UTF-16 encoding, horizontal. + UniJisUtf16H, + /// `UniJIS-UTF16-V` — Adobe-Japan1, Unicode UTF-16 encoding, vertical. + UniJisUtf16V, + + // ── Adobe-Korea1 (Korean) ── Unicode encodings ── + /// `UniKS-UCS2-H` — Adobe-Korea1, Unicode UCS-2 encoding, horizontal. + UniKsUcs2H, + /// `UniKS-UCS2-V` — Adobe-Korea1, Unicode UCS-2 encoding, vertical. + UniKsUcs2V, + /// `UniKS-UTF16-H` — Adobe-Korea1, Unicode UTF-16 encoding, horizontal. + UniKsUtf16H, + /// `UniKS-UTF16-V` — Adobe-Korea1, Unicode UTF-16 encoding, vertical. + UniKsUtf16V, + + // ── Adobe-Japan1 (Japanese) ── JIS encoding ── + /// `V` — Adobe-Japan1, JIS X 0208 row-cell encoding, vertical. + V, + + /// A custom (non-predefined) `CMap` name. + Custom(&'a [u8]), +} + +impl<'a> CMapName<'a> { + /// Create a `CMapType` from raw bytes. + pub fn from_bytes(name: &'a [u8]) -> Self { + match name { + b"83pv-RKSJ-H" => Self::N83pvRksjH, + b"90ms-RKSJ-H" => Self::N90msRksjH, + b"90ms-RKSJ-V" => Self::N90msRksjV, + b"90msp-RKSJ-H" => Self::N90mspRksjH, + b"90msp-RKSJ-V" => Self::N90mspRksjV, + b"90pv-RKSJ-H" => Self::N90pvRksjH, + b"Add-RKSJ-H" => Self::AddRksjH, + b"Add-RKSJ-V" => Self::AddRksjV, + b"Adobe-CNS1-UCS2" => Self::AdobeCns1Ucs2, + b"Adobe-GB1-UCS2" => Self::AdobeGb1Ucs2, + b"Adobe-Japan1-UCS2" => Self::AdobeJapan1Ucs2, + b"Adobe-Korea1-UCS2" => Self::AdobeKorea1Ucs2, + b"B5pc-H" => Self::B5pcH, + b"B5pc-V" => Self::B5pcV, + b"CNS-EUC-H" => Self::CnsEucH, + b"CNS-EUC-V" => Self::CnsEucV, + b"ETen-B5-H" => Self::ETenB5H, + b"ETen-B5-V" => Self::ETenB5V, + b"ETenms-B5-H" => Self::ETenmsB5H, + b"ETenms-B5-V" => Self::ETenmsB5V, + b"EUC-H" => Self::EucH, + b"EUC-V" => Self::EucV, + b"Ext-RKSJ-H" => Self::ExtRksjH, + b"Ext-RKSJ-V" => Self::ExtRksjV, + b"GB-EUC-H" => Self::GbEucH, + b"GB-EUC-V" => Self::GbEucV, + b"GBK-EUC-H" => Self::GbkEucH, + b"GBK-EUC-V" => Self::GbkEucV, + b"GBK2K-H" => Self::Gbk2kH, + b"GBK2K-V" => Self::Gbk2kV, + b"GBKp-EUC-H" => Self::GbkpEucH, + b"GBKp-EUC-V" => Self::GbkpEucV, + b"GBpc-EUC-H" => Self::GbpcEucH, + b"GBpc-EUC-V" => Self::GbpcEucV, + b"H" => Self::H, + b"HKscs-B5-H" => Self::HKscsB5H, + b"HKscs-B5-V" => Self::HKscsB5V, + b"Identity-H" => Self::IdentityH, + b"Identity-V" => Self::IdentityV, + b"KSC-EUC-H" => Self::KscEucH, + b"KSC-EUC-V" => Self::KscEucV, + b"KSCms-UHC-H" => Self::KscmsUhcH, + b"KSCms-UHC-HW-H" => Self::KscmsUhcHwH, + b"KSCms-UHC-HW-V" => Self::KscmsUhcHwV, + b"KSCms-UHC-V" => Self::KscmsUhcV, + b"KSCpc-EUC-H" => Self::KscpcEucH, + b"UniCNS-UCS2-H" => Self::UniCnsUcs2H, + b"UniCNS-UCS2-V" => Self::UniCnsUcs2V, + b"UniCNS-UTF16-H" => Self::UniCnsUtf16H, + b"UniCNS-UTF16-V" => Self::UniCnsUtf16V, + b"UniGB-UCS2-H" => Self::UniGbUcs2H, + b"UniGB-UCS2-V" => Self::UniGbUcs2V, + b"UniGB-UTF16-H" => Self::UniGbUtf16H, + b"UniGB-UTF16-V" => Self::UniGbUtf16V, + b"UniJIS-UCS2-H" => Self::UniJisUcs2H, + b"UniJIS-UCS2-HW-H" => Self::UniJisUcs2HwH, + b"UniJIS-UCS2-HW-V" => Self::UniJisUcs2HwV, + b"UniJIS-UCS2-V" => Self::UniJisUcs2V, + b"UniJIS-UTF16-H" => Self::UniJisUtf16H, + b"UniJIS-UTF16-V" => Self::UniJisUtf16V, + b"UniKS-UCS2-H" => Self::UniKsUcs2H, + b"UniKS-UCS2-V" => Self::UniKsUcs2V, + b"UniKS-UTF16-H" => Self::UniKsUtf16H, + b"UniKS-UTF16-V" => Self::UniKsUtf16V, + b"V" => Self::V, + _ => Self::Custom(name), + } + } + + /// Convert the `CMapType` back to its raw byte representation. + pub fn to_bytes(&self) -> &[u8] { + match self { + Self::N83pvRksjH => b"83pv-RKSJ-H", + Self::N90msRksjH => b"90ms-RKSJ-H", + Self::N90msRksjV => b"90ms-RKSJ-V", + Self::N90mspRksjH => b"90msp-RKSJ-H", + Self::N90mspRksjV => b"90msp-RKSJ-V", + Self::N90pvRksjH => b"90pv-RKSJ-H", + Self::AddRksjH => b"Add-RKSJ-H", + Self::AddRksjV => b"Add-RKSJ-V", + Self::AdobeCns1Ucs2 => b"Adobe-CNS1-UCS2", + Self::AdobeGb1Ucs2 => b"Adobe-GB1-UCS2", + Self::AdobeJapan1Ucs2 => b"Adobe-Japan1-UCS2", + Self::AdobeKorea1Ucs2 => b"Adobe-Korea1-UCS2", + Self::B5pcH => b"B5pc-H", + Self::B5pcV => b"B5pc-V", + Self::CnsEucH => b"CNS-EUC-H", + Self::CnsEucV => b"CNS-EUC-V", + Self::ETenB5H => b"ETen-B5-H", + Self::ETenB5V => b"ETen-B5-V", + Self::ETenmsB5H => b"ETenms-B5-H", + Self::ETenmsB5V => b"ETenms-B5-V", + Self::EucH => b"EUC-H", + Self::EucV => b"EUC-V", + Self::ExtRksjH => b"Ext-RKSJ-H", + Self::ExtRksjV => b"Ext-RKSJ-V", + Self::GbEucH => b"GB-EUC-H", + Self::GbEucV => b"GB-EUC-V", + Self::GbkEucH => b"GBK-EUC-H", + Self::GbkEucV => b"GBK-EUC-V", + Self::Gbk2kH => b"GBK2K-H", + Self::Gbk2kV => b"GBK2K-V", + Self::GbkpEucH => b"GBKp-EUC-H", + Self::GbkpEucV => b"GBKp-EUC-V", + Self::GbpcEucH => b"GBpc-EUC-H", + Self::GbpcEucV => b"GBpc-EUC-V", + Self::H => b"H", + Self::HKscsB5H => b"HKscs-B5-H", + Self::HKscsB5V => b"HKscs-B5-V", + Self::IdentityH => b"Identity-H", + Self::IdentityV => b"Identity-V", + Self::KscEucH => b"KSC-EUC-H", + Self::KscEucV => b"KSC-EUC-V", + Self::KscmsUhcH => b"KSCms-UHC-H", + Self::KscmsUhcHwH => b"KSCms-UHC-HW-H", + Self::KscmsUhcHwV => b"KSCms-UHC-HW-V", + Self::KscmsUhcV => b"KSCms-UHC-V", + Self::KscpcEucH => b"KSCpc-EUC-H", + Self::UniCnsUcs2H => b"UniCNS-UCS2-H", + Self::UniCnsUcs2V => b"UniCNS-UCS2-V", + Self::UniCnsUtf16H => b"UniCNS-UTF16-H", + Self::UniCnsUtf16V => b"UniCNS-UTF16-V", + Self::UniGbUcs2H => b"UniGB-UCS2-H", + Self::UniGbUcs2V => b"UniGB-UCS2-V", + Self::UniGbUtf16H => b"UniGB-UTF16-H", + Self::UniGbUtf16V => b"UniGB-UTF16-V", + Self::UniJisUcs2H => b"UniJIS-UCS2-H", + Self::UniJisUcs2HwH => b"UniJIS-UCS2-HW-H", + Self::UniJisUcs2HwV => b"UniJIS-UCS2-HW-V", + Self::UniJisUcs2V => b"UniJIS-UCS2-V", + Self::UniJisUtf16H => b"UniJIS-UTF16-H", + Self::UniJisUtf16V => b"UniJIS-UTF16-V", + Self::UniKsUcs2H => b"UniKS-UCS2-H", + Self::UniKsUcs2V => b"UniKS-UCS2-V", + Self::UniKsUtf16H => b"UniKS-UTF16-H", + Self::UniKsUtf16V => b"UniKS-UTF16-V", + Self::V => b"V", + Self::Custom(name) => name, + } + } +} + +/// Let's limit the number of nested `usecmap` references to 16. +const MAX_NESTING_DEPTH: u32 = 16; + +/// A parsed cmap. +#[derive(Debug, Clone)] +pub struct CMap { + metadata: Metadata, + // Note that we don't actually use this, because Acrobat _seems_ to ignore + // it, too. + _codespace_ranges: Vec, + cid_ranges: PartitionedRanges, + notdef_ranges: PartitionedRanges, + bf_entries: Vec, + base: Option>, +} + +impl CMap { + /// Parse a cmap from raw bytes. + /// + /// The `get_cmap` callback is used to recursively resolve cmaps that + /// are referenced via `usecmap`. + pub fn parse<'a>( + data: &[u8], + get_cmap: impl Fn(CMapName<'_>) -> Option<&'a [u8]> + Clone + 'a, + ) -> Option { + parse::parse_inner(data, get_cmap, 0) + } + + /// Create an Identity-H cmap. + pub fn identity_h() -> Self { + Self::identity(WritingMode::Horizontal, b"Identity-H") + } + + /// Create an Identity-V cmap. + pub fn identity_v() -> Self { + Self::identity(WritingMode::Vertical, b"Identity-V") + } + + fn identity(writing_mode: WritingMode, name: &[u8]) -> Self { + Self { + metadata: Metadata { + character_collection: Some(CharacterCollection { + family: CidFamily::AdobeIdentity, + supplement: 0, + }), + name: Some(Vec::from(name)), + writing_mode: Some(writing_mode), + }, + _codespace_ranges: Vec::new(), + cid_ranges: { + let mut r = PartitionedRanges::new(); + r.push( + 2, + CidRange { + range: Range { + start: 0, + end: 0xFFFF, + }, + cid_start: 0, + }, + ); + r + }, + notdef_ranges: PartitionedRanges::new(), + bf_entries: Vec::new(), + base: None, + } + } + + /// Return the metadata of this cmap. + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + /// Look up the CID code of a character code. + /// + /// Returns `None` if the code does not match any range for the given byte length. + pub fn lookup_cid_code(&self, code: u32, byte_len: u8) -> Option { + // Note that, in theory, we are supposed to first check the code space range + // whether the entry exists in the first place. However, from my experiments + // Acrobat mostly seems to ignore this, so we do that as well. + + let cid_ranges = self.cid_ranges.get(byte_len)?; + let notdef_ranges = self.notdef_ranges.get(byte_len)?; + + if let Some(entry) = find_in_ranges(cid_ranges, code) { + let offset = code.checked_sub(entry.range.start)?; + return entry.cid_start.checked_add(offset); + } else if let Some(entry) = find_in_ranges(notdef_ranges, code) { + // For `.notdef` ranges, all codes map to the same `.notdef` CID, so + // no adding of the offset here. + return Some(entry.cid_start); + } + + // See pdfjs_bug920426, it uses bf chars for encoding the characters + // of a text-showing operator, so try that as well. We don't want to + // recurse though, only check the bf chars of this level. + if let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false) { + return Some(lookup as u32); + } + + // If we still haven't found anything, check the base cmap. + self.base + .as_ref() + .and_then(|b| b.lookup_cid_code(code, byte_len)) + } + + /// Look up a bf string in the cmap. This is usually + /// used for mapping character codes to Unicode codepoints in a + /// `ToUnicode` cmap. + /// + /// Returns `None` if no mapping is available. + pub fn lookup_bf_string(&self, code: u32) -> Option { + self.lookup_bf_string_inner(code, true) + } + + fn lookup_bf_string_inner(&self, code: u32, recurse: bool) -> Option { + if let Some(entry) = find_in_ranges(&self.bf_entries, code) { + let offset = u16::try_from(code - entry.range.start).ok()?; + + fn decode_utf16(units: &[u16]) -> Option { + let mut iter = core::char::decode_utf16(units.iter().copied()); + let first = iter.next()?.ok()?; + + if iter.next().is_none() { + Some(BfString::Char(first)) + } else { + let s = String::from_utf16(units).ok()?; + Some(BfString::String(s)) + } + } + + return if offset == 0 { + Some(decode_utf16(&entry.dst_base)?) + } else { + let mut units = entry.dst_base.clone(); + *units.last_mut()? = units.last()?.checked_add(offset)?; + Some(decode_utf16(&units)?) + }; + } + + if recurse { + self.base.as_ref()?.lookup_bf_string(code) + } else { + None + } + } +} + +trait HasRange { + fn range(&self) -> &Range; +} + +fn find_in_ranges(entries: &[T], code: u32) -> Option<&T> { + let idx = entries + .binary_search_by(|entry| { + let r = entry.range(); + if code < r.start { + core::cmp::Ordering::Greater + } else if code > r.end { + core::cmp::Ordering::Less + } else { + core::cmp::Ordering::Equal + } + }) + .ok()?; + + Some(&entries[idx]) +} + +/// A range with a start and end code. +#[derive(Debug, Clone)] +pub(crate) struct Range { + pub(crate) start: u32, + pub(crate) end: u32, +} + +/// A range of character codes mapped to CIDs. +#[derive(Debug, Clone)] +pub struct CidRange { + pub(crate) range: Range, + pub(crate) cid_start: Cid, +} + +impl HasRange for CidRange { + fn range(&self) -> &Range { + &self.range + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PartitionedRanges { + buckets: [Vec; 4], +} + +impl PartitionedRanges { + pub(crate) fn new() -> Self { + Self { + buckets: [Vec::new(), Vec::new(), Vec::new(), Vec::new()], + } + } + + // PdfCraft patch: needed by the truncated-stream guard in parse.rs. + pub(crate) fn is_empty(&self) -> bool { + self.buckets.iter().all(|b| b.is_empty()) + } + + pub(crate) fn push(&mut self, byte_len: usize, range: CidRange) { + if let Some(bucket) = byte_len + .checked_sub(1) + .and_then(|i| self.buckets.get_mut(i)) + { + bucket.push(range); + } + } + + pub(crate) fn get(&self, byte_len: u8) -> Option<&[CidRange]> { + let idx = (byte_len as usize).checked_sub(1)?; + self.buckets.get(idx).map(|v| v.as_slice()) + } + + pub(crate) fn sort(&mut self) { + for bucket in &mut self.buckets { + bucket.sort_by(|a, b| a.range.start.cmp(&b.range.start)); + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct BfRange { + pub(crate) range: Range, + pub(crate) dst_base: Vec, +} + +impl HasRange for BfRange { + fn range(&self) -> &Range { + &self.range + } +} + +/// A codespace range defining valid character code byte sequences. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub(crate) struct CodespaceRange { + pub(crate) number_bytes: u8, + pub(crate) low: u32, + pub(crate) high: u32, +} + +/// A Unicode value decoded from a cmap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BfString { + /// A single character. + Char(char), + /// A UTF-8 string. + String(String), +} + +/// Metadata extracted from a cmap file. +#[derive(Debug, Clone)] +pub struct Metadata { + /// The referenced character collection. + pub character_collection: Option, + /// The cmap name. + pub name: Option>, + /// The writing mode. + pub writing_mode: Option, +} + +/// The registry+ordering family of a CID character collection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CidFamily { + /// Adobe-Japan1 + AdobeJapan1, + /// Adobe-GB1 + AdobeGB1, + /// Adobe-CNS1 + AdobeCNS1, + /// Adobe-Korea1 + AdobeKorea1, + /// Adobe-Identity + AdobeIdentity, + /// A non-predefined registry/ordering pair. + Custom { + /// The registry name. + registry: Vec, + /// The ordering name. + ordering: Vec, + }, +} + +impl CidFamily { + /// Create a `CidFamily` from raw registry and ordering byte strings. + pub fn from_registry_ordering(registry: &[u8], ordering: &[u8]) -> Self { + if registry == b"Adobe" { + match ordering { + b"Japan1" => Self::AdobeJapan1, + b"GB1" => Self::AdobeGB1, + b"CNS1" => Self::AdobeCNS1, + b"Korea1" => Self::AdobeKorea1, + b"Identity" => Self::AdobeIdentity, + _ => Self::Custom { + registry: registry.to_vec(), + ordering: ordering.to_vec(), + }, + } + } else { + Self::Custom { + registry: registry.to_vec(), + ordering: ordering.to_vec(), + } + } + } + + /// Return the predefined UTF-16 `CMap` that maps Unicode code points to CIDs + /// for this family, if one exists. + pub fn unicode_cmap(&self) -> Option> { + match self { + Self::AdobeJapan1 => Some(CMapName::UniJisUtf16H), + Self::AdobeGB1 => Some(CMapName::UniGbUtf16H), + Self::AdobeCNS1 => Some(CMapName::UniCnsUtf16H), + Self::AdobeKorea1 => Some(CMapName::UniKsUtf16H), + Self::AdobeIdentity | Self::Custom { .. } => None, + } + } + + /// Return the `UCS2` cmap corresponding to the given character collection. + pub fn ucs2_cmap(&self) -> Option> { + match self { + Self::AdobeJapan1 => Some(CMapName::AdobeJapan1Ucs2), + Self::AdobeGB1 => Some(CMapName::AdobeGb1Ucs2), + Self::AdobeCNS1 => Some(CMapName::AdobeCns1Ucs2), + Self::AdobeKorea1 => Some(CMapName::AdobeKorea1Ucs2), + Self::AdobeIdentity | Self::Custom { .. } => None, + } + } +} + +/// A CID character collection identifying the character set and ordering. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CharacterCollection { + /// The registry+ordering family. + pub family: CidFamily, + /// The supplement number. + pub supplement: i32, +} + +/// The writing mode of a cmap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WritingMode { + /// Horizontal writing mode. + #[default] + Horizontal, + /// Vertical writing mode. + Vertical, +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note that those cmaps might not be completely valid according to the rules + // of cmap/Postscript, but since our parser is very lenient and doesn't run a real + // interpreter we can shorten them by a lot. + + const PREAMBLE: &[u8] = br#"/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 0 def +end def +/CMapName /Test def +/WMode 0 def +2 begincodespacerange +<00> +<0000> +endcodespacerange +"#; + + fn parse_with_preamble(body: &[u8]) -> CMap { + let mut data = Vec::new(); + data.extend_from_slice(PREAMBLE); + data.extend_from_slice(body); + CMap::parse(&data, |_| None).unwrap() + } + + /// PdfCraft patch test: a binary CMap whose `usecmap` names itself stops at the nesting limit. + #[cfg(feature = "embed-cmaps")] + #[test] + fn self_referencing_binary_usecmap_stops_at_the_nesting_limit() { + // Header (magic, version, file length 19), then one usecmap segment (length 9) naming "Self". + const SELF_REF: &[u8] = &[ + b'b', b'c', b'm', b'a', b'p', 1, 0, 0, 0, 19, 0x09, 0, 0, 0, 9, b'S', b'e', b'l', b'f', + ]; + assert!(CMap::parse(SELF_REF, |_| Some(SELF_REF)).is_none()); + } + + #[test] + fn metadata_parsing() { + let data = br#" +/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 6 def +end def +/CMapName /Adobe-Japan1-H def +/CMapType 1 def +/WMode 0 def +endcmap"#; + + let cmap = CMap::parse(data, |_| None).unwrap(); + let cc = cmap.metadata().character_collection.as_ref().unwrap(); + assert_eq!(cc.family, CidFamily::AdobeJapan1); + assert_eq!(cc.supplement, 6); + assert_eq!( + cmap.metadata().name.as_deref(), + Some(b"Adobe-Japan1-H".as_slice()) + ); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Horizontal)); + } + + #[test] + fn vertical_writing_mode() { + let data = br#" +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 6 def +end def +/CMapName /Adobe-Japan1-V def +/WMode 1 def +"#; + + let cmap = CMap::parse(data, |_| None).unwrap(); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Vertical)); + assert_eq!( + cmap.metadata().name.as_deref(), + Some(b"Adobe-Japan1-V".as_slice()) + ); + } + + #[test] + fn cid_range_lookup() { + let cmap = parse_with_preamble( + br#" +3 begincidrange +<0000> <00FF> 0 +<0100> <01FF> 256 +<8140> <817E> 633 +endcidrange +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x0042, 2), Some(0x42)); + assert_eq!(cmap.lookup_cid_code(0x00FF, 2), Some(0xFF)); + + assert_eq!(cmap.lookup_cid_code(0x0100, 2), Some(256)); + assert_eq!(cmap.lookup_cid_code(0x01FF, 2), Some(511)); + + assert_eq!(cmap.lookup_cid_code(0x8140, 2), Some(633)); + assert_eq!(cmap.lookup_cid_code(0x817E, 2), Some(633 + 62)); + } + + #[test] + fn cid_char_lookup() { + let cmap = parse_with_preamble( + br#" +3 begincidchar +<03> 1 +<04> 2 +<20> 50 +endcidchar +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x03, 1), Some(1)); + assert_eq!(cmap.lookup_cid_code(0x04, 1), Some(2)); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(50)); + } + + #[test] + fn lookup_miss() { + let cmap = parse_with_preamble( + br#" +1 begincidrange +<0100> <01FF> 0 +endcidrange +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x00FF, 2), None); + assert_eq!(cmap.lookup_cid_code(0x0200, 2), None); + assert_eq!(cmap.lookup_cid_code(0xFFFF, 2), None); + } + + #[test] + fn multiple_sections() { + let cmap = parse_with_preamble( + br#" +2 begincidrange +<0000> <00FF> 0 +<0100> <01FF> 256 +endcidrange +1 begincidchar +<0200> 600 +endcidchar +1 begincidrange +<8140> <817E> 633 +endcidrange +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x0100, 2), Some(256)); + assert_eq!(cmap.lookup_cid_code(0x0200, 2), Some(600)); + assert_eq!(cmap.lookup_cid_code(0x8140, 2), Some(633)); + } + + #[test] + fn single_byte_codes() { + let cmap = parse_with_preamble( + br#" +2 begincidrange +<00> <7F> 0 +<80> 200 +endcidrange +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x00, 1), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(0x41)); + assert_eq!(cmap.lookup_cid_code(0x80, 1), Some(200)); + assert_eq!(cmap.lookup_cid_code(0xFF, 1), Some(200 + 127)); + } + + #[test] + fn dict_style_cidsysteminfo() { + let data = br#" +/CIDInit /ProcSet findresource begin +10 dict begin +begincmap +/CIDSystemInfo +<< /Registry (Adobe) +/Ordering (UCS) +/Supplement 0 +>> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<0000> +endcodespacerange +2 beginbfchar +<001F> +<002A> +endbfchar +endcmap +CMapName currentdict /CMap defineresource pop +end +end +"#; + let cmap = CMap::parse(data, |_| None).unwrap(); + let cc = cmap.metadata().character_collection.as_ref().unwrap(); + assert_eq!( + cc.family, + CidFamily::Custom { + registry: b"Adobe".to_vec(), + ordering: b"UCS".to_vec(), + } + ); + assert_eq!(cc.supplement, 0); + assert_eq!( + cmap.metadata().name.as_deref(), + Some(b"Adobe-Identity-UCS".as_slice()) + ); + assert_eq!( + cmap.lookup_bf_string(0x001F), + Some(BfString::Char('\u{F049}')) + ); + assert_eq!( + cmap.lookup_bf_string(0x002A), + Some(BfString::Char('\u{F055}')) + ); + } + + #[test] + fn usecmap_chaining() { + let base_data = br#" +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 0 def +end def +/CMapName /Base def +/WMode 0 def +1 begincodespacerange +<0000> +endcodespacerange +1 begincidrange +<0000> <00FF> 0 +endcidrange +"#; + + let child_data = br#" +/Base usecmap +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 0 def +end def +/CMapName /Child def +/WMode 0 def +1 begincodespacerange +<0000> +endcodespacerange +1 begincidrange +<0100> <01FF> 256 +endcidrange +"#; + + let cmap = CMap::parse(child_data, |name| { + if name.to_bytes() == b"Base" { + Some(base_data.as_slice()) + } else { + None + } + }) + .unwrap(); + + assert_eq!(cmap.lookup_cid_code(0x0100, 2), Some(256)); + assert_eq!(cmap.lookup_cid_code(0x01FF, 2), Some(511)); + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x00FF, 2), Some(0xFF)); + + assert_eq!(cmap.lookup_cid_code(0x0200, 2), None); + } + + #[test] + fn usecmap_partial_override() { + let base_data = br#" +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 0 def +end def +/CMapName /Base def +/WMode 0 def +1 begincodespacerange +<0000> +endcodespacerange +1 begincidrange +<0000> <00FF> 0 +endcidrange +"#; + + let child_data = br#" +/Base usecmap +/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Japan1) def + /Supplement 0 def +end def +/CMapName /Child def +/WMode 0 def +1 begincodespacerange +<0000> +endcodespacerange +1 begincidrange +<0040> <007F> 500 +endcidrange +"#; + + let cmap = CMap::parse(child_data, |name| { + if name.to_bytes() == b"Base" { + Some(base_data.as_slice()) + } else { + None + } + }) + .unwrap(); + + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x003F, 2), Some(0x3F)); + assert_eq!(cmap.lookup_cid_code(0x0040, 2), Some(500)); + assert_eq!(cmap.lookup_cid_code(0x007F, 2), Some(563)); + assert_eq!(cmap.lookup_cid_code(0x0080, 2), Some(0x80)); + assert_eq!(cmap.lookup_cid_code(0x00FF, 2), Some(0xFF)); + } + + #[test] + fn notdef_char_lookup() { + let cmap = parse_with_preamble( + br#" +2 beginnotdefchar +<03> 10 +<20> 20 +endnotdefchar +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x03, 1), Some(10)); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(20)); + assert_eq!(cmap.lookup_cid_code(0x04, 1), None); + } + + #[test] + fn notdef_range_lookup() { + let cmap = parse_with_preamble( + br#" +1 beginnotdefrange +<0000> <001F> 100 +endnotdefrange +"#, + ); + + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(100)); + assert_eq!(cmap.lookup_cid_code(0x0001, 2), Some(100)); + assert_eq!(cmap.lookup_cid_code(0x001F, 2), Some(100)); + assert_eq!(cmap.lookup_cid_code(0x0020, 2), None); + } + + #[test] + fn bfchar_lookup() { + let cmap = parse_with_preamble( + br#" +2 beginbfchar +<0041> <0048> +<0042> <0065> +endbfchar +"#, + ); + + assert_eq!(cmap.lookup_bf_string(0x0041), Some(BfString::Char('H'))); + assert_eq!(cmap.lookup_bf_string(0x0042), Some(BfString::Char('e'))); + assert_eq!(cmap.lookup_bf_string(0x0043), None); + } + + #[test] + fn bfchar_ligature() { + let cmap = parse_with_preamble( + br#" +1 beginbfchar +<005F> <00660066> +endbfchar +"#, + ); + + assert_eq!( + cmap.lookup_bf_string(0x005F), + Some(BfString::String(String::from("ff"))) + ); + } + + // PdfCraft patch: malformed pieces of a CMap are skipped, not fatal - the reading + // path used to discard the whole CMap and fall back to the font encoding for every + // code, so search and search-redaction missed text the edit layer could see (#3177). + + #[test] + fn bad_bfchar_entry_is_skipped() { + let cmap = parse_with_preamble( + br#" +2 beginbfchar +<41> +<42> <0059> +endbfchar +"#, + ); + assert_eq!(cmap.lookup_bf_string(0x41), None); + assert_eq!( + cmap.lookup_bf_string(0x42), + Some(BfString::Char('Y')) + ); + } + + #[test] + fn stream_cut_off_inside_a_block_keeps_the_entries_before_it() { + let cmap = parse_with_preamble(b"1 beginbfchar <41> <0058>\n<42> <00"); + assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('X'))); + assert_eq!(cmap.lookup_bf_string(0x42), None); + } + + #[test] + fn truncated_stream_with_no_entries_reports_failure() { + // The tolerant parser keeps collected entries, but a stream that + // breaks before any entry must still report failure so callers + // fall back to the font's own encoding. + assert!(CMap::parse(b"<00", |_| None).is_none()); + } + + #[test] + fn truncated_stream_with_only_metadata_reports_failure() { + // Character-collection metadata alone is not a usable CMap: an empty + // metadata-only map would block the caller's fallback paths, so a + // truncated stream with no mappings must report failure here too. + assert!(CMap::parse( + b"/Registry (Adobe) /Ordering (Japan1) /Supplement 0 <00", + |_| None + ) + .is_none()); + } + + #[test] + fn unresolved_usecmap_with_no_mappings_reports_failure() { + // A stream whose only content is a `usecmap` reference that cannot be + // resolved yields no mappings at all; returning an empty map here + // would block the caller's encoding fallback paths, so it must + // report failure exactly as a full parse failure did before. + assert!(CMap::parse(b"/Bogus usecmap", |_| None).is_none()); + } + + #[test] + fn unresolved_usecmap_keeps_local_mappings() { + // The same unresolved reference must not discard mappings the stream + // carries itself: only the missing base is dropped. + let cmap = CMap::parse( + b"/Bogus usecmap 1 beginbfchar <41> <0041> endbfchar", + |_| None, + ) + .expect("local mappings survive an unresolved usecmap"); + assert!(cmap.lookup_bf_string(0x41).is_some()); + } + + #[test] + fn bad_bfchar_code_keeps_later_entries_aligned() { + // The malformed 5-byte code still has a destination object; both + // must be skipped or every later pair shifts by one object. + let cmap = parse_with_preamble( + br#" +2 beginbfchar +<0102030405> <0041> +<42> <0059> +endbfchar +"#, + ); + assert_eq!(cmap.lookup_bf_string(0x41), None); + assert_eq!(cmap.lookup_bf_string(0x42), Some(BfString::Char('Y'))); + } + + #[test] + fn bfrange_array_stops_at_exhaustion() { + // One member for 256 codes: the array cannot be resynced, so the + // loop must stop rather than spin over the remaining codes. + let cmap = parse_with_preamble( + br#" +1 beginbfrange +<0000> <00FF> [<0041>] +endbfrange +"#, + ); + assert_eq!(cmap.lookup_bf_string(0x00), Some(BfString::Char('A'))); + assert_eq!(cmap.lookup_bf_string(0x01), None); + } + + #[test] + fn unresolvable_usecmap_is_skipped() { + let cmap = parse_with_preamble( + br#" +/Bogus usecmap +1 beginbfchar +<41> <0058> +endbfchar +"#, + ); + assert_eq!( + cmap.lookup_bf_string(0x41), + Some(BfString::Char('X')) + ); + } + + #[test] + fn bfchar_surrogate_pair() { + let cmap = parse_with_preamble( + br#" +1 beginbfchar +<3A51> +endbfchar +"#, + ); + + assert_eq!( + cmap.lookup_bf_string(0x3A51), + Some(BfString::Char('\u{2003E}')) + ); + } + + #[test] + fn bfrange_incrementing() { + let cmap = parse_with_preamble( + br#" +1 beginbfrange +<0000> <0004> <0041> +endbfrange +"#, + ); + + assert_eq!(cmap.lookup_bf_string(0x0000), Some(BfString::Char('A'))); + assert_eq!(cmap.lookup_bf_string(0x0001), Some(BfString::Char('B'))); + assert_eq!(cmap.lookup_bf_string(0x0004), Some(BfString::Char('E'))); + assert_eq!(cmap.lookup_bf_string(0x0005), None); + } + + #[test] + fn bfrange_array() { + let cmap = parse_with_preamble( + br#" +1 beginbfrange +<005F> <0061> [<00660066> <00660069> <0066006C>] +endbfrange +"#, + ); + + // ff, fi, fl ligatures + assert_eq!( + cmap.lookup_bf_string(0x005F), + Some(BfString::String(String::from("ff"))) + ); + assert_eq!( + cmap.lookup_bf_string(0x0060), + Some(BfString::String(String::from("fi"))) + ); + assert_eq!( + cmap.lookup_bf_string(0x0061), + Some(BfString::String(String::from("fl"))) + ); + } + + #[test] + fn unicode_lookup_miss() { + let cmap = parse_with_preamble( + br#" +1 beginbfchar +<0041> <0048> +endbfchar +"#, + ); + + assert_eq!(cmap.lookup_bf_string(0x0000), None); + assert_eq!(cmap.lookup_bf_string(0x0042), None); + } + + #[test] + fn identity_h() { + let cmap = CMap::identity_h(); + assert_eq!( + cmap.metadata().name.as_deref(), + Some(b"Identity-H".as_slice()) + ); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Horizontal)); + + assert_eq!(cmap.lookup_cid_code(0x0041, 2), Some(0x0041)); + assert_eq!(cmap.lookup_cid_code(0x1234, 2), Some(0x1234)); + assert_eq!(cmap.lookup_cid_code(0xFFFF, 2), Some(0xFFFF)); + + assert_eq!(cmap.lookup_cid_code(0x0041, 1), None); + assert_eq!(cmap.lookup_cid_code(0x0041, 3), None); + } + + #[test] + fn identity_v() { + let cmap = CMap::identity_v(); + assert_eq!( + cmap.metadata().name.as_deref(), + Some(b"Identity-V".as_slice()) + ); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Vertical)); + + assert_eq!(cmap.lookup_cid_code(0x0041, 2), Some(0x0041)); + assert_eq!(cmap.lookup_cid_code(0xFFFF, 2), Some(0xFFFF)); + } + + #[test] + fn minimal_cmap_no_name_no_wmode() { + // Extracted from corpus PDF 0500013. + let data = br#" +/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CMapType 2 def +1 begincodespacerange +<00> +endcodespacerange +1 beginbfrange +<01><01><0020> +endbfrange +endcmap +"#; + let cmap = CMap::parse(data, |_| None).unwrap(); + assert_eq!(cmap.metadata().name, None); + assert_eq!(cmap.metadata().character_collection, None); + assert_eq!(cmap.metadata().writing_mode, None); + assert_eq!(cmap.lookup_bf_string(0x01), Some(BfString::Char(' '))); + } + + #[test] + fn registry_as_name() { + // Extracted from corpus PDF 0875241, uses names instead of strings for + // strings for metadata. + let data = br#" +/CIDSystemInfo +<< /Registry /ABCDEF+SimSun +/Ordering (pdfbeaninc) +/Supplement 0 +>> def +/CMapName /ABCDEF+SimSun def +/CMapType 2 def +1 begincodespacerange +<0000> +endcodespacerange +1 beginbfrange +<0000> <0000> <6881> +endbfrange +"#; + let cmap = CMap::parse(data, |_| None).unwrap(); + let cc = cmap.metadata().character_collection.as_ref().unwrap(); + assert_eq!( + cc.family, + CidFamily::Custom { + registry: b"ABCDEF+SimSun".to_vec(), + ordering: b"pdfbeaninc".to_vec(), + } + ); + assert_eq!(cc.supplement, 0); + assert_eq!( + cmap.lookup_bf_string(0x0000), + Some(BfString::Char('\u{6881}')) + ); + } + + #[test] + fn single_byte_bfrange_destination() { + // See for example corpus test case 0625248. + let cmap = parse_with_preamble( + br#" +1 beginbfrange +<00> <7f> <00> +endbfrange +"#, + ); + + assert_eq!( + cmap.lookup_bf_string(0x00), + Some(BfString::Char('\u{0000}')) + ); + assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('A'))); + assert_eq!( + cmap.lookup_bf_string(0x7f), + Some(BfString::Char('\u{007F}')) + ); + } +} + +#[cfg(all(test, feature = "embed-cmaps"))] +mod bcmap_tests { + use super::*; + + fn get_embedded_cmap(name: CMapName<'_>) -> Option<&'static [u8]> { + load_embedded(name) + } + + #[test] + fn embedded_h_cmap() { + let data = load_embedded(CMapName::H).expect("embedded H cmap not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse H cmap"); + assert_eq!(cmap.metadata().writing_mode, None); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeJapan1, + supplement: 1, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x2121, 2), Some(633)); + assert_eq!(cmap.lookup_cid_code(0x2221, 2), Some(727)); + } + + #[test] + fn embedded_90ms_rksj_h() { + let data = + load_embedded(CMapName::N90msRksjH).expect("embedded 90ms-RKSJ-H cmap not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse 90ms-RKSJ-H cmap"); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeJapan1, + supplement: 2, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(231)); + assert_eq!(cmap.lookup_cid_code(0x8140, 2), Some(633)); + } + + #[test] + fn embedded_v_cmap() { + let data = load_embedded(CMapName::V).expect("embedded V cmap not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse V cmap"); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Vertical)); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeJapan1, + supplement: 1, + }) + ); + // Inherited from base "H" via usecmap. + assert_eq!(cmap.lookup_cid_code(0x2121, 2), Some(633)); + } + + #[test] + fn embedded_gbk_euc_h() { + let data = load_embedded(CMapName::GbkEucH).expect("embedded GBK-EUC-H not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse GBK-EUC-H"); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeGB1, + supplement: 2, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(7716)); + assert_eq!(cmap.lookup_cid_code(0x21, 1), Some(814)); + assert_eq!(cmap.lookup_cid_code(0x7E, 1), Some(814 + 0x7E - 0x21)); + assert_eq!(cmap.lookup_cid_code(0xFE80, 2), Some(22094)); + assert_eq!(cmap.lookup_cid_code(0xFEA0, 2), Some(22094 + 0xA0 - 0x80)); + } + + #[test] + fn embedded_ksc_euc_h() { + let data = load_embedded(CMapName::KscEucH).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeKorea1, + supplement: 0, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(8094)); + assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(8094 + 0x41 - 0x20)); + assert_eq!(cmap.lookup_cid_code(0xA1A1, 2), Some(101)); + assert_eq!(cmap.lookup_cid_code(0xA1FE, 2), Some(101 + 0xFE - 0xA1)); + assert_eq!(cmap.lookup_cid_code(0xFDA1, 2), Some(7962)); + } + + #[test] + fn embedded_b5pc_h() { + let data = load_embedded(CMapName::B5pcH).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeCNS1, + supplement: 0, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(1)); + assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(1 + 0x41 - 0x20)); + assert_eq!(cmap.lookup_cid_code(0x80, 1), Some(61)); + assert_eq!(cmap.lookup_cid_code(0xD140, 2), Some(7251)); + assert_eq!(cmap.lookup_cid_code(0xD17E, 2), Some(7251 + 0x7E - 0x40)); + assert_eq!(cmap.lookup_cid_code(0xF9D5, 2), Some(13642 + 3)); + } + + #[test] + fn embedded_unijis_utf16_h() { + let data = load_embedded(CMapName::UniJisUtf16H).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeJapan1, + supplement: 7, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 2), Some(1)); + assert_eq!(cmap.lookup_cid_code(0x41, 2), Some(1 + 0x41 - 0x20)); + assert_eq!(cmap.lookup_cid_code(0x5C, 2), Some(97)); + assert_eq!(cmap.lookup_cid_code(0x5BCC, 2), Some(3531)); + assert_eq!(cmap.lookup_cid_code(0xD884DF50, 4), Some(19130)); + } + + #[test] + fn embedded_identity_h() { + let data = load_embedded(CMapName::IdentityH).expect("embedded Identity-H not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse Identity-H cmap"); + assert_eq!(cmap.metadata().writing_mode, None); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeIdentity, + supplement: 0, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x0041, 2), Some(0x41)); + assert_eq!(cmap.lookup_cid_code(0xFFFF, 2), Some(0xFFFF)); + } + + #[test] + fn embedded_identity_v() { + let data = load_embedded(CMapName::IdentityV).expect("embedded Identity-V not found"); + let cmap = CMap::parse(data, get_embedded_cmap).expect("failed to parse Identity-V cmap"); + assert_eq!(cmap.metadata().writing_mode, Some(WritingMode::Vertical)); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeIdentity, + supplement: 0, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x0000, 2), Some(0)); + assert_eq!(cmap.lookup_cid_code(0x0041, 2), Some(0x41)); + assert_eq!(cmap.lookup_cid_code(0xFFFF, 2), Some(0xFFFF)); + } + + // Note: Some tests AI-generated, haven't double-checked them. + + #[test] + fn embedded_adobe_gb1_ucs2() { + let data = load_embedded(CMapName::AdobeGb1Ucs2).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::Custom { + registry: b"Adobe".to_vec(), + ordering: b"Adobe_GB1_UCS2".to_vec(), + }, + supplement: 5, + }) + ); + + assert_eq!( + cmap.lookup_bf_string(0x0063), + Some(BfString::Char('\u{00B7}')) + ); + assert_eq!(cmap.lookup_bf_string(0x0001), Some(BfString::Char(' '))); + assert_eq!(cmap.lookup_bf_string(0x0022), Some(BfString::Char('A'))); + assert_eq!(cmap.lookup_bf_string(0x005F), Some(BfString::Char('~'))); + assert_eq!( + cmap.lookup_bf_string(0x560F), + Some(BfString::String(String::from("\u{90CE}\u{FE00}"))) + ); + } + + #[test] + fn embedded_adobe_japan1_ucs2() { + let data = load_embedded(CMapName::AdobeJapan1Ucs2).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::Custom { + registry: b"Adobe".to_vec(), + ordering: b"Adobe_Japan1_UCS2".to_vec(), + }, + supplement: 7, + }) + ); + + assert_eq!( + cmap.lookup_bf_string(0x003D), + Some(BfString::Char('\u{00A5}')) + ); + assert_eq!(cmap.lookup_bf_string(0x0001), Some(BfString::Char(' '))); + assert_eq!(cmap.lookup_bf_string(0x003C), Some(BfString::Char('['))); + assert_eq!( + cmap.lookup_bf_string(0x0476), + Some(BfString::String(String::from("\u{82A6}\u{E0100}"))) + ); + assert_eq!( + cmap.lookup_bf_string(0x265B), + Some(BfString::String(String::from("10/11"))) + ); + assert_eq!( + cmap.lookup_bf_string(0x2E6B), + Some(BfString::String(String::from("オングストローム"))) + ); + } + + #[test] + fn embedded_adobe_korea1_ucs2() { + let data = load_embedded(CMapName::AdobeKorea1Ucs2).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::Custom { + registry: b"Adobe".to_vec(), + ordering: b"Adobe_Korea1_UCS2".to_vec(), + }, + supplement: 2, + }) + ); + + assert_eq!( + cmap.lookup_bf_string(0x0060), + Some(BfString::Char('\u{20A9}')) + ); + assert_eq!( + cmap.lookup_bf_string(0x2072), + Some(BfString::String(String::from("[10]"))) + ); + assert_eq!( + cmap.lookup_bf_string(0x2073), + Some(BfString::String(String::from("[11]"))) + ); + } + + #[test] + fn embedded_adobe_cns1_ucs2() { + let data = load_embedded(CMapName::AdobeCns1Ucs2).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::Custom { + registry: b"Adobe".to_vec(), + ordering: b"Adobe_CNS1_UCS2".to_vec(), + }, + supplement: 7, + }) + ); + + assert_eq!( + cmap.lookup_bf_string(0x0060), + Some(BfString::Char('\u{00A9}')) + ); + assert_eq!(cmap.lookup_bf_string(0x0001), Some(BfString::Char(' '))); + assert_eq!(cmap.lookup_bf_string(0x005F), Some(BfString::Char('~'))); + assert_eq!( + cmap.lookup_bf_string(0x36B0), + Some(BfString::Char('\u{200CC}')) + ); + } + + #[test] + fn embedded_eten_b5_h() { + let data = load_embedded(CMapName::ETenB5H).unwrap(); + let cmap = CMap::parse(data, get_embedded_cmap).unwrap(); + assert_eq!( + cmap.metadata().character_collection, + Some(CharacterCollection { + family: CidFamily::AdobeCNS1, + supplement: 0, + }) + ); + assert_eq!(cmap.lookup_cid_code(0x20, 1), Some(13648)); + assert_eq!(cmap.lookup_cid_code(0x7E, 1), Some(13648 + 0x7E - 0x20)); + assert_eq!(cmap.lookup_cid_code(0xA140, 2), Some(99)); + assert_eq!(cmap.lookup_cid_code(0xA158, 2), Some(99 + 0x58 - 0x40)); + assert_eq!(cmap.lookup_cid_code(0xD040, 2), Some(7094)); + assert_eq!(cmap.lookup_cid_code(0xF9FE, 2), Some(14056 + 0xFE - 0xD6)); + } +} diff --git a/vendor/hayro-cmap/src/parse.rs b/vendor/hayro-cmap/src/parse.rs new file mode 100644 index 00000000..a9362092 --- /dev/null +++ b/vendor/hayro-cmap/src/parse.rs @@ -0,0 +1,469 @@ +use alloc::boxed::Box; +use alloc::vec::Vec; + +use hayro_postscript::{Object, Scanner}; + +#[cfg(feature = "embed-cmaps")] +use crate::bcmap; +use crate::{ + BfRange, CMap, CMapName, CharacterCollection, CidFamily, CidRange, CodespaceRange, + MAX_NESTING_DEPTH, Metadata, PartitionedRanges, Range, WritingMode, +}; + +struct Context { + buf: Vec, + get_cmap: F, +} + +pub(crate) fn parse_inner<'a>( + data: &[u8], + get_cmap: impl Fn(CMapName<'_>) -> Option<&'a [u8]> + Clone + 'a, + depth: u32, +) -> Option { + // Prevent stack overflow for malicious cmap files or circular references. + if depth >= MAX_NESTING_DEPTH { + return None; + } + + // Check if it's in our custom embedded cmap format. + #[cfg(feature = "embed-cmaps")] + if data.starts_with(b"bcmap") { + return bcmap::parse(data, get_cmap, depth); + } + + let mut scanner = Scanner::new(data); + let mut ctx = Context { + buf: Vec::new(), + get_cmap, + }; + let mut _codespace_ranges = Vec::new(); + let mut ranges = PartitionedRanges::new(); + let mut notdef_ranges = PartitionedRanges::new(); + let mut bf_entries = Vec::new(); + let mut base = None; + + let mut registry = None; + let mut ordering = None; + let mut supplement = None; + let mut cmap_name = None; + let mut writing_mode = None; + let mut last_name: Option> = None; + + // PdfCraft patch: track whether the scan was cut short. A CMap with an + // unparseable tail, including one that ends inside a `begin…` block, + // keeps the entries read so far (the block parsers push into the shared + // lists as they go) instead of being discarded wholesale; one that + // yielded nothing still reports failure. + let mut truncated = false; + + // PdfCraft patch: track a `usecmap` reference that could not be resolved + // or parsed. When the stream yields no mappings of its own, the caller + // must see failure rather than an empty map, so its encoding fallback + // paths run exactly as they did before the tolerant-usecmap change. + let mut usecmap_failed = false; + while !scanner.at_end() { + let Ok(obj) = scanner.parse_object() else { + truncated = true; + break; + }; + + let Object::Name(name) = &obj else { continue }; + + if name.is_literal() { + match name.as_str() { + // Strictly speaking, Registry and Ordering should be strings, + // but some PDF generators emit names instead, so be lenient + // and try both. + Some("Registry") => { + registry = parse_string_or_name(&mut scanner); + } + Some("Ordering") => { + ordering = parse_string_or_name(&mut scanner); + } + Some("Supplement") => { + supplement = scanner.parse_number().ok().map(|n| n.as_i32()); + } + Some("CMapName") => { + cmap_name = scanner.parse_name().ok().and_then(|n| n.decode().ok()); + } + Some("WMode") => { + writing_mode = parse_writing_mode(&mut scanner); + } + _ => { + last_name = name.decode().ok(); + } + } + } else { + match name.as_str() { + Some("begincodespacerange") => { + if parse_codespace_range(&mut scanner, &mut _codespace_ranges, &mut ctx).is_none() { + truncated = true; + break; + } + } + Some("begincidrange") => { + if parse_range(&mut scanner, &mut ranges, &mut ctx, "endcidrange").is_none() { + truncated = true; + break; + } + } + Some("begincidchar") => { + if parse_char(&mut scanner, &mut ranges, &mut ctx, "endcidchar").is_none() { + truncated = true; + break; + } + } + Some("beginnotdefrange") => { + if parse_range(&mut scanner, &mut notdef_ranges, &mut ctx, "endnotdefrange").is_none() { + truncated = true; + break; + } + } + Some("beginnotdefchar") => { + if parse_char(&mut scanner, &mut notdef_ranges, &mut ctx, "endnotdefchar").is_none() { + truncated = true; + break; + } + } + Some("beginbfchar") => { + if parse_bf_char(&mut scanner, &mut bf_entries, &mut ctx).is_none() { + truncated = true; + break; + } + } + Some("beginbfrange") => { + if parse_bf_range(&mut scanner, &mut bf_entries, &mut ctx).is_none() { + truncated = true; + break; + } + } + Some("usecmap") => { + // PdfCraft patch: an unresolvable or unparseable `usecmap` + // reference drops only the base, not the whole CMap, but + // is remembered so a stream that then yields no mappings + // at all still reports failure. + let resolved = last_name.as_deref().and_then(|name| { + (ctx.get_cmap)(CMapName::from_bytes(name)) + }); + match resolved { + Some(nested_data) => { + match parse_inner(nested_data, ctx.get_cmap.clone(), depth + 1) { + Some(nested) => base = Some(Box::new(nested)), + None => usecmap_failed = true, + } + } + None => usecmap_failed = true, + } + } + _ => {} + } + } + } + + + // Since we will use binary search for finding the correct entry, sort now. + ranges.sort(); + notdef_ranges.sort(); + bf_entries.sort_by(|a, b| a.range.start.cmp(&b.range.start)); + + // See PDFJS-3323, which has an invalid CIDSystemInfo entry. + // We ignore it if it's invalid. + let character_collection = if let (Some(registry), Some(ordering), Some(supplement)) = + (registry, ordering, supplement) + { + Some(CharacterCollection { + family: CidFamily::from_registry_ordering(®istry, &ordering), + supplement, + }) + } else { + None + }; + + let metadata = Metadata { + character_collection, + name: cmap_name, + writing_mode, + }; + + // PdfCraft patch: a CMap that was cut short or whose `usecmap` base could + // not be resolved, and that yielded no mappings of its own, still reports + // failure, so callers fall back to the font's own encoding exactly as + // before. + if (truncated || usecmap_failed) + && ranges.is_empty() + && notdef_ranges.is_empty() + && bf_entries.is_empty() + && base.is_none() + { + return None; + } + + Some(CMap { + metadata, + _codespace_ranges, + cid_ranges: ranges, + notdef_ranges, + bf_entries, + base, + }) +} + +fn parse_writing_mode(scanner: &mut Scanner<'_>) -> Option { + match scanner.parse_number().ok()?.as_i32() { + 0 => Some(WritingMode::Horizontal), + 1 => Some(WritingMode::Vertical), + _ => None, + } +} + +fn parse_string_or_name(scanner: &mut Scanner<'_>) -> Option> { + match scanner.parse_object().ok()? { + Object::String(s) => s.decode().ok(), + Object::Name(n) => n.decode().ok(), + _ => None, + } +} + +fn parse_codespace_range( + scanner: &mut Scanner<'_>, + ranges: &mut Vec, + ctx: &mut Context, +) -> Option<()> { + loop { + let obj = scanner.parse_object().ok()?; + + if name_matches(&obj, "endcodespacerange") { + return Some(()); + } + + // PdfCraft patch: a malformed codespace entry is skipped, not fatal. + let Some(low) = extract_u32_code(&obj, &mut ctx.buf) else { continue }; + let Ok(n_bytes) = u8::try_from(ctx.buf.len()) else { continue }; + let Some(high) = read_u32_code(scanner, &mut ctx.buf) else { continue }; + + if ctx.buf.len() != usize::from(n_bytes) { + continue; + } + + ranges.push(CodespaceRange { + number_bytes: n_bytes, + low, + high, + }); + } +} + +fn parse_range( + scanner: &mut Scanner<'_>, + ranges: &mut PartitionedRanges, + ctx: &mut Context, + end_marker: &str, +) -> Option<()> { + loop { + let obj = scanner.parse_object().ok()?; + + if name_matches(&obj, end_marker) { + return Some(()); + } + + // PdfCraft patch: a malformed range entry is skipped, not fatal. + let Some(start) = extract_u32_code(&obj, &mut ctx.buf) else { continue }; + let byte_len = ctx.buf.len(); + let Some(end) = read_u32_code(scanner, &mut ctx.buf) else { continue }; + let Some(cid_start) = scanner.parse_number().ok().and_then(|n| u32::try_from(n.as_i32()).ok()) else { continue }; + + ranges.push( + byte_len, + CidRange { + range: Range { start, end }, + cid_start, + }, + ); + } +} + +fn parse_char( + scanner: &mut Scanner<'_>, + ranges: &mut PartitionedRanges, + ctx: &mut Context, + end_marker: &str, +) -> Option<()> { + loop { + let obj = scanner.parse_object().ok()?; + + if name_matches(&obj, end_marker) { + return Some(()); + } + + // PdfCraft patch: a malformed char entry is skipped, not fatal. + let Some(code) = extract_u32_code(&obj, &mut ctx.buf) else { continue }; + let byte_len = ctx.buf.len(); + let Some(cid_start) = scanner.parse_number().ok().and_then(|n| u32::try_from(n.as_i32()).ok()) else { continue }; + + ranges.push( + byte_len, + CidRange { + range: Range { + start: code, + end: code, + }, + cid_start, + }, + ); + } +} + +fn parse_bf_char( + scanner: &mut Scanner<'_>, + entries: &mut Vec, + ctx: &mut Context, +) -> Option<()> { + loop { + let obj = scanner.parse_object().ok()?; + + if name_matches(&obj, "endbfchar") { + return Some(()); + } + + // PdfCraft patch: a malformed bfchar entry (e.g. a non-hex destination) + // is skipped, not fatal - the entries after it still apply. + let Some(code) = extract_u32_code(&obj, &mut ctx.buf) else { + // The malformed code is still followed by its destination + // object; skip it so later entries stay aligned. + let _ = scanner.parse_object(); + continue; + }; + let Ok(dst) = scanner.parse_string() else { continue }; + if dst.decode_into(&mut ctx.buf).is_err() { continue } + let Some(dst_base) = decode_be(&ctx.buf) else { continue }; + + entries.push(BfRange { + range: Range { + start: code, + end: code, + }, + dst_base, + }); + } +} + +fn parse_bf_range( + scanner: &mut Scanner<'_>, + entries: &mut Vec, + ctx: &mut Context, +) -> Option<()> { + loop { + let obj = scanner.parse_object().ok()?; + + if name_matches(&obj, "endbfrange") { + return Some(()); + } + + // PdfCraft patch: a malformed bfrange entry is skipped, not fatal. + let Some(start) = extract_u32_code(&obj, &mut ctx.buf) else { + // Skip the range's end and destination objects too, so later + // entries stay aligned. + let _ = scanner.parse_object(); + let _ = scanner.parse_object(); + continue; + }; + let Some(end) = read_u32_code(scanner, &mut ctx.buf) else { + // Skip the destination object as well to stay aligned. + let _ = scanner.parse_object(); + continue; + }; + + let Ok(next) = scanner.parse_object() else { continue }; + + match &next { + Object::String(s) => { + if s.decode_into(&mut ctx.buf).is_err() { continue } + let Some(dst_base) = decode_be(&ctx.buf) else { continue }; + + entries.push(BfRange { + range: Range { start, end }, + dst_base, + }); + } + Object::Array(array) => { + let mut array_scanner = array.objects(); + + for code in start..=end { + // An exhausted or broken array cannot be resynced to a + // member boundary; stop instead of spinning over the + // remaining codes. A decodable-but-bad member still + // drops only itself. + let Ok(s) = array_scanner.parse_string() else { break }; + if s.decode_into(&mut ctx.buf).is_err() { continue } + let Some(dst_base) = decode_be(&ctx.buf) else { continue }; + + entries.push(BfRange { + range: Range { + start: code, + end: code, + }, + dst_base, + }); + } + } + _ => continue, + } + } +} + +/// Convert the buffer into native-endian u16, so that we can use `String::from_utf16`. +fn decode_be(bytes: &[u8]) -> Option> { + if bytes.is_empty() { + return None; + } + + let mut out = Vec::with_capacity(bytes.len().div_ceil(2)); + let mut i = 0; + + // My understanding is that the bf strings should always be UTF16-BE encoded, + // but it seems like some PDFs only have a single byte there? I guess we just + // pad them? Wasn't able to find anything specific in the specifications. + if !bytes.len().is_multiple_of(2) { + out.push(u16::from(bytes[0])); + i = 1; + } + + while i < bytes.len() { + out.push(u16::from_be_bytes([bytes[i], bytes[i + 1]])); + i += 2; + } + + Some(out) +} + +#[inline] +fn read_u32_code(scanner: &mut Scanner<'_>, buf: &mut Vec) -> Option { + let s = scanner.parse_string().ok()?; + s.decode_into(buf).ok()?; + bytes_to_u32(buf) +} + +#[inline] +fn extract_u32_code(obj: &Object<'_>, buf: &mut Vec) -> Option { + let Object::String(s) = obj else { return None }; + s.decode_into(buf).ok()?; + bytes_to_u32(buf) +} + +#[inline] +fn bytes_to_u32(bytes: &[u8]) -> Option { + if bytes.is_empty() || bytes.len() > 4 { + return None; + } + + let mut val = 0_u32; + for &b in bytes { + val = (val << 8) | b as u32; + } + + Some(val) +} + +fn name_matches(obj: &Object<'_>, expected: &str) -> bool { + matches!(obj, Object::Name(name) if !name.is_literal() && name.as_str() == Some(expected)) +} diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 2ef84143..4ea8f56d 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -218,7 +218,7 @@ "path": "src/font/cid.rs", "source_sha256": "c1dbe7fe5b7f7092d97b84063008df54aebf87db4ba1fdb3a179fab9fe6674b8", "source_git_blob": "b99dcbc116ca1169df3a229eafd05ee5b9204626", - "sha256": "c1dbe7fe5b7f7092d97b84063008df54aebf87db4ba1fdb3a179fab9fe6674b8" + "sha256": "a0f7e140ddea72e08e78ebc996d4136947fd9bb28911b8745bf05d4941fcf320" }, { "path": "src/font/generated/glyph_names.rs", @@ -290,7 +290,7 @@ "path": "src/font/mod.rs", "source_sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b", "source_git_blob": "5ccf5c48a6132b7704baff81ece3492a6a7acae2", - "sha256": "f7a8cfe30893115cc05906cbec747ad368b5645de101c8cbf7bf622f7611973b" + "sha256": "0de994e24c9413e2be1b6116ffe5ea44659df05bf61afdb60e14107630854492" }, { "path": "src/font/outline.rs", @@ -302,25 +302,25 @@ "path": "src/font/standard_font.rs", "source_sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46", "source_git_blob": "c73c75dd22c7937585a0646f49bc89514ce054df", - "sha256": "048bcfb0d7d63ced49cef583c954b7507e5f078313caf5cf565adb97ad4bda46" + "sha256": "01241c51fe85d94fa1bc3963e16a45be7c292104b204c37704e636ba2c0b0c5d" }, { "path": "src/font/true_type.rs", "source_sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84", "source_git_blob": "40f33590309100f295cb441069c340f9e034b49b", - "sha256": "8e8ffcf8f9749b10262793f1ecaf86ac7c65e6d5608e013d938a03adf210ad84" + "sha256": "a1262fd7efcf358b85b7863c97733a40e5df4295a9d1b54f66b1bada5898f600" }, { "path": "src/font/type1.rs", "source_sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4", "source_git_blob": "32b5e3ddd9a2ca877d760bc5150bd55c3302bb33", - "sha256": "5779b825fa6a5589978ed5b30910b7af0986ace42bac4e3652f7975a479874d4" + "sha256": "a8eabb741ce3991bca11a4f72f42658b33802b8688dda4cc5904e3e4cdae9955" }, { "path": "src/font/type3.rs", "source_sha256": "257f7a2c8780c8e565f46dfd7dd1e3d50bef1e2c44aae5777994addb0ca26d62", "source_git_blob": "ccfeeb766472f16f15b04710bab711f669b94adf", - "sha256": "5bd99e9a219c4ed437f964b3b81e10381ad4f0bfdee8b2f9be1a6d3940fcd9b4" + "sha256": "1778362c6c4a5cb015e756fd3f36afd7482f20a230dde0206f4339da922eaca4" }, { "path": "src/function/mod.rs", @@ -332,7 +332,7 @@ "path": "src/function/type0.rs", "source_sha256": "84cb6ccb45536ca5731394558883350f43b5e66330e07eb8d5a0e070c8e20704", "source_git_blob": "02bbc41b84caddc9b5dfc8f49b2da081eb281199", - "sha256": "84cb6ccb45536ca5731394558883350f43b5e66330e07eb8d5a0e070c8e20704" + "sha256": "89aab3f20d8c3c30629dbccc18ce099a39eff7e4a88b4ef4685fc0415008c233" }, { "path": "src/function/type2.rs", diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index b99dcbc1..ed32cfe4 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -45,9 +45,16 @@ 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 shows + /// such text as the code's 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 { + /// Reads a Type 0 font from its dictionary: the descendant CID font, the encoding CMap and + /// the code-to-Unicode map (`/ToUnicode`, the `/Identity-H` name, or the UCS2 CMap). pub(crate) fn new( dict: &Dict<'_>, font_resolver: &FontResolverFn, @@ -117,6 +124,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 +172,7 @@ impl Type0Font { fallback, to_unicode_is_cid_indexed, adobe_collection, + identity_to_unicode, }) } @@ -264,7 +275,13 @@ impl Type0Font { { return Some(glyph); } - charmap.map(character) + // PdfCraft patch: a substitute may lack a spacing character (#3601; e.g. + // Adobe-Japan1's U+2002). Draw its ordinary space instead of .notdef; + // the PDF's widths and Unicode mapping still determine advance and text. + charmap.map(character).or_else(|| match character { + '\u{00a0}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}' => charmap.map(' '), + _ => None, + }) } FontType::Cff(c) => { // Map codepoint to glyph name via AFL, and then look it up. @@ -430,7 +447,15 @@ impl Type0Font { } } + /// The text a character code stands for, or `None` when the font gives none (or, for an + /// identity `/ToUnicode` name, when the code is a control or a lone surrogate). 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) @@ -592,6 +617,12 @@ fn read_widths2(arr: &Array<'_>) -> Option> { let mut iter = range.iter::(); while let Some(w) = iter.next() { + // PdfCraft patch: as in `read_widths`, checked before the run's metrics are + // read. Without it, a run starting at 4294967295 overflowed `first` and + // discarded the whole /W2 table. + if first > MAX_CID { + break; + } let v1 = iter.next()?; let v2 = iter.next()?; map.insert(first, [w, v1, v2]); @@ -643,3 +674,22 @@ fn read_encoding(object: &Object<'_>, cmap_resolver: &CMapResolverFn) -> Option< _ => None, } } + +#[cfg(test)] +mod tests { + use super::read_widths2; + use hayro_syntax::object::{Array, FromBytes}; + + #[test] + fn w2_run_past_the_largest_cid_keeps_the_rest_of_the_table() { + let arr = + Array::from_bytes(b"[4294967295 [1000 500] 10 [900 450 860] 20 30 800 400 870]") + .unwrap(); + let map = read_widths2(&arr).unwrap(); + + assert_eq!(map.get(&4_294_967_295), None); + assert_eq!(map.get(&10), Some(&[900.0, 450.0, 860.0])); + assert_eq!(map.get(&25), Some(&[800.0, 400.0, 870.0])); + assert_eq!(map.len(), 12); + } +} diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 5ccf5c48..103ee396 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -444,6 +444,29 @@ impl Encoding { Self::BuiltIn => None, } } + + /// PdfCraft patch: the glyph name an Annex D.2 *alias code* reads as when the glyph is taken + /// as text rather than looked up in a font program; `None` for every other code. + /// + /// Annex D.2 gives WinAnsiEncoding codes 0xA0 and 0xAD the glyph names `space` and `hyphen`, + /// the same names it gives 0x20 and 0x2D, and MacRomanEncoding 0xCA `space`. The tables above + /// call them `nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are + /// *formatting* characters, so extraction hid ink the reader can see: a soft hyphen is + /// invisible, and text that drew one matched no search and no redaction pattern -- one corpus + /// file alone produced 104 of them -- while a non-breaking space did not match the space a + /// reader typed. pdftotext reads both codes as the ordinary character. + /// + /// Only the base table is aliased, in the text direction only. A `/Differences` entry naming + /// `sfthyphen` or `nbspace` is a deliberate statement and keeps its glyph list value, which + /// is what pdftotext does too; and glyph *lookup* is untouched, so a font program's own + /// `sfthyphen` program is still found first. + fn text_alias(&self, code: u8) -> Option<&'static str> { + match (self, code) { + (Self::WinAnsi, 0xA0) | (Self::MacRoman, 0xCA) => Some("space"), + (Self::WinAnsi, 0xAD) => Some("hyphen"), + _ => None, + } + } } /// The font stretch. @@ -646,6 +669,49 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option { }) } +/// PdfCraft patch: the text of a glyph name `glyph_name_to_unicode` doesn't know, by the +/// Adobe Glyph List Specification's full mapping: drop everything from the first `.` +/// (`hyphen.case`, `a.sc`), split the rest on `_` into components (`f_i`, `f_f_l`), and map each +/// one through the glyph list, as `uniXXXX[XXXX…]` (BMP values, four hex digits each) or as +/// `uXXXX` to `uXXXXXX`. A component it can't map, or a `uni` component with any group that +/// isn't a character, adds nothing; `None` when nothing is left. +pub(crate) fn glyph_name_to_text(name: &str) -> Option { + let hex = |h: &str| u32::from_str_radix(h, 16).ok().and_then(char::from_u32); + // The specification's digits are uppercase only (`uni20ac` maps to nothing, review on + // #3239). + let upper_hex = |d: &str| d.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); + let base = name.split('.').next().unwrap_or_default(); + let mut text = String::new(); + for part in base.split('_') { + if let Some(s) = glyph_names::get(part) { + text.push_str(s); + } else if let Some(digits) = part + .strip_prefix("uni") + .filter(|d| !d.is_empty() && d.len() % 4 == 0 && upper_hex(d)) + { + // All ASCII, so every fourth byte is a char boundary. A group that isn't a + // character (a surrogate) makes the whole component map to nothing. + let chars: Option = (0..digits.len()) + .step_by(4) + .map(|at| digits.get(at..at + 4).and_then(hex)) + .collect(); + text.extend(chars); + } else if let Some(c) = part + .strip_prefix('u') + .filter(|d| (4..=6).contains(&d.len()) && upper_hex(d)) + .and_then(hex) + { + text.push(c); + } + } + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (None, _) => None, + (Some(c), None) => Some(BfString::Char(c)), + _ => Some(BfString::String(text)), + } +} + pub(crate) fn unicode_from_name(name: &str) -> Option { let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32); diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index c73c75dd..5941160f 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -397,7 +397,7 @@ impl StandardKind { }) } - fn code_to_ps_name(&self, code: u8) -> Option<&str> { + pub(crate) fn code_to_ps_name(&self, code: u8) -> Option<&str> { let bf = self.base_font; self.encodings @@ -445,7 +445,8 @@ impl StandardKind { /// PdfCraft patch: the outline of a glyph from [`Self::composite_glyph`] — the base letter, /// then the accent centred on it. An accent above a letter taller than `x` is raised by the /// difference (the stand-in's accents sit on lowercase letters) and, in an italic font, - /// moved along the slant; an ogonek hangs from the letter's right edge. + /// moved along the slant; an ogonek hangs from the letter's right edge; a comma below is + /// the font's comma, smaller, under the baseline. fn composite_outline(&self, glyph: GlyphId) -> Option { let code = u8::try_from(glyph.to_u32().checked_sub(COMPOSITE_GLYPH_BASE)?).ok()?; let (base, accent, place) = accented_parts(self.code_to_ps_name(code)?)?; @@ -455,13 +456,27 @@ impl StandardKind { .map(|g| self.base_font_blob.outline_glyph(g)) }; let mut path = outline(base)?; - let accent = outline(accent)?; + let mut accent = outline(accent)?; if path.elements().is_empty() || accent.elements().is_empty() { return Some(path); } + if place == Place::CommaBelow { + accent = Affine::scale(COMMA_BELOW_SCALE) * accent; + } let (b, a) = (path.bounding_box(), accent.bounding_box()); let (dx, dy) = match place { Place::Below => (b.center().x - a.center().x, 0.0), + Place::CommaBelow => { + // Its top a little below the baseline (or the letter's overshoot below it), and + // in an italic font moved back along the slant by as much as it is lowered. + let dy = b.y0.min(0.0) - COMMA_BELOW_GAP * a.height() - a.y1; + let slant = if self.base_font.is_italic() { + dy * ITALIC_ACCENT_SLANT + } else { + 0.0 + }; + (b.center().x - a.center().x + slant, dy) + } Place::Ogonek => (b.x1 - a.x1, 0.0), Place::Above => { let x_top = outline("x") @@ -546,6 +561,14 @@ impl StandardKind { } pub(crate) fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } @@ -574,12 +597,21 @@ const COMPOSITE_GLYPH_BASE: u32 = 0x0100_0000; /// an italic stand-in (tan 15°; the standard italics lean 12° to 16°). const ITALIC_ACCENT_SLANT: f64 = 0.27; +/// PdfCraft patch: the size of a comma accent against the font's comma. A full-size comma +/// moved under the baseline reaches past the descenders and into the line below. +const COMMA_BELOW_SCALE: f64 = 0.75; + +/// PdfCraft patch: the gap between the baseline and a comma accent, as a share of its height. +const COMMA_BELOW_GAP: f64 = 0.15; + /// Where an accent goes on its base letter. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Place { Above, Below, Ogonek, + /// The font's comma, smaller, under the letter (the stand-ins have no `commaaccent`). + CommaBelow, } /// PdfCraft patch: the stand-ins for the standard fonts (PDFium's Foxit fonts) carry only the @@ -588,7 +620,11 @@ enum Place { /// iText 2 writes Turkish text with Times New Roman left unembedded) drew nothing for them. Each of /// these Latin Extended-A letters is a base letter the stand-ins have plus one of their accents, /// as Type 1 `seac` composites and the composites of the standard-14 AFM files are built. -/// Letters whose accent isn't a plain one (ď ľ ť and the comma accents) aren't here. +/// The comma-below letters of Romanian (ș ț) and Latvian (ķ ļ ņ ŗ Ģ) are the base letter and the +/// font's comma, since the stand-ins have no `commaaccent` either; without them a form laid out +/// from its XFA template drew Romanian ș as a blank and ț with a cedilla. Letters whose accent +/// isn't a plain one (ď ľ ť, whose caron is an apostrophe beside the letter, and ģ, whose comma +/// is turned and above it) aren't here. const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("Amacron", "A", "macron", Place::Above), ("amacron", "a", "macron", Place::Above), @@ -620,6 +656,7 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("Gbreve", "G", "breve", Place::Above), ("gbreve", "g", "breve", Place::Above), ("Gdotaccent", "G", "dotaccent", Place::Above), + ("Gcommaaccent", "G", "comma", Place::CommaBelow), ("gdotaccent", "g", "dotaccent", Place::Above), ("Hcircumflex", "H", "circumflex", Place::Above), ("hcircumflex", "h", "circumflex", Place::Above), @@ -633,10 +670,16 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("iogonek", "i", "ogonek", Place::Ogonek), ("Idotaccent", "I", "dotaccent", Place::Above), ("Jcircumflex", "J", "circumflex", Place::Above), + ("Kcommaaccent", "K", "comma", Place::CommaBelow), + ("kcommaaccent", "k", "comma", Place::CommaBelow), ("Lacute", "L", "acute", Place::Above), ("lacute", "l", "acute", Place::Above), + ("Lcommaaccent", "L", "comma", Place::CommaBelow), + ("lcommaaccent", "l", "comma", Place::CommaBelow), ("Nacute", "N", "acute", Place::Above), ("nacute", "n", "acute", Place::Above), + ("Ncommaaccent", "N", "comma", Place::CommaBelow), + ("ncommaaccent", "n", "comma", Place::CommaBelow), ("Ncaron", "N", "caron", Place::Above), ("ncaron", "n", "caron", Place::Above), ("Omacron", "O", "macron", Place::Above), @@ -647,6 +690,8 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("ohungarumlaut", "o", "hungarumlaut", Place::Above), ("Racute", "R", "acute", Place::Above), ("racute", "r", "acute", Place::Above), + ("Rcommaaccent", "R", "comma", Place::CommaBelow), + ("rcommaaccent", "r", "comma", Place::CommaBelow), ("Rcaron", "R", "caron", Place::Above), ("rcaron", "r", "caron", Place::Above), ("Sacute", "S", "acute", Place::Above), @@ -655,8 +700,12 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("scircumflex", "s", "circumflex", Place::Above), ("Scedilla", "S", "cedilla", Place::Below), ("scedilla", "s", "cedilla", Place::Below), + ("Scommaaccent", "S", "comma", Place::CommaBelow), + ("scommaaccent", "s", "comma", Place::CommaBelow), ("Tcedilla", "T", "cedilla", Place::Below), ("tcedilla", "t", "cedilla", Place::Below), + ("Tcommaaccent", "T", "comma", Place::CommaBelow), + ("tcommaaccent", "t", "comma", Place::CommaBelow), ("Tcaron", "T", "caron", Place::Above), ("Utilde", "U", "tilde", Place::Above), ("utilde", "u", "tilde", Place::Above), @@ -680,8 +729,28 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("zdotaccent", "z", "dotaccent", Place::Above), ]; +/// PdfCraft patch: the comma-below letters of [`ACCENTED_LETTERS`] by character. The glyph list +/// names the Latvian ones `Kcedilla`… (their comma is a cedilla in Unicode's names), has no name +/// for Ț ț, and gives `Tcommaaccent` and `tcommaaccent` to Ţ ţ, which keep their cedilla. +const COMMA_BELOW_BY_CHAR: &[(char, &str)] = &[ + ('\u{0122}', "Gcommaaccent"), + ('\u{0136}', "Kcommaaccent"), + ('\u{0137}', "kcommaaccent"), + ('\u{013B}', "Lcommaaccent"), + ('\u{013C}', "lcommaaccent"), + ('\u{0145}', "Ncommaaccent"), + ('\u{0146}', "ncommaaccent"), + ('\u{0156}', "Rcommaaccent"), + ('\u{0157}', "rcommaaccent"), + ('\u{0218}', "Scommaaccent"), + ('\u{0219}', "scommaaccent"), + ('\u{021A}', "Tcommaaccent"), + ('\u{021B}', "tcommaaccent"), +]; + /// PdfCraft patch: the base letter, accent and placement of an accented letter in -/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too). +/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too). A comma-below letter +/// is found by its character ([`COMMA_BELOW_BY_CHAR`]), so `/uni0136` and `/Kcedilla` are Ķ too. fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { let name = normalized_glyph_name(name); let by_name = |n: &str| { @@ -690,5 +759,11 @@ fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { .find(|(letter, ..)| *letter == n) .map(|&(_, base, accent, place)| (base, accent, place)) }; - by_name(name).or_else(|| by_name(glyph_names::get_reverse(glyph_name_to_unicode(name)?)?)) + by_name(name).or_else(|| { + let c = glyph_name_to_unicode(name)?; + match COMMA_BELOW_BY_CHAR.iter().find(|(letter, _)| *letter == c) { + Some((_, comma_below)) => by_name(comma_below), + None => by_name(glyph_names::get_reverse(c)?), + } + }) } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 40f33590..c6504399 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -2,7 +2,7 @@ use crate::font::blob::{CffFontBlob, OpenTypeFontBlob}; use crate::font::generated::{glyph_names, mac_os_roman, mac_roman, standard, win_ansi}; use crate::font::standard_font::StandardKind; use crate::font::{ - Encoding, FallbackFontQuery, FontFlags, glyph_name_to_unicode, read_to_unicode, + Encoding, FallbackFontQuery, FontFlags, glyph_name_to_text, glyph_name_to_unicode, read_to_unicode, strip_subset_prefix, unicode_from_name, }; use crate::util::OptionLog; @@ -190,10 +190,11 @@ impl TrueTypeFont { } match &self.kind { - Kind::Embedded(e) => e - .code_to_unicode_name(code as u8) - .and_then(glyph_name_to_unicode) - .map(BfString::Char), + // PdfCraft patch (#3169): a name outside the glyph list reads by the glyph list + // specification's full mapping (`f_i`, `hyphen.case`). + Kind::Embedded(e) => e.code_to_unicode_name(code as u8).and_then(|name| { + glyph_name_to_unicode(name).map(BfString::Char).or_else(|| glyph_name_to_text(name)) + }), Kind::Standard(s) => s.char_code_to_unicode(code as u8).map(BfString::Char), } @@ -286,7 +287,13 @@ impl EmbeddedKind { self.differences .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // The last resort reads the code as WinAnsi whatever the font said, so it takes the + // same aliasing. + .or_else(|| Encoding::WinAnsi.text_alias(code)) .or_else(|| win_ansi::get(code)) } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 32b5e3dd..4ef5f953 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -2,7 +2,8 @@ use crate::font::blob::{CffFontBlob, Type1FontBlob}; use crate::font::standard_font::{StandardFont, StandardKind, select_standard_font}; use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ - Encoding, FallbackFontQuery, glyph_name_to_unicode, normalized_glyph_name, read_to_unicode, + Encoding, FallbackFontQuery, glyph_name_to_text, glyph_name_to_unicode, normalized_glyph_name, + read_to_unicode, }; use crate::{CMapResolverFn, CacheKey, FontResolverFn}; use hayro_cmap::{BfString, CMap}; @@ -117,11 +118,25 @@ impl Type1Font { } let code = char_code as u8; - match &self.1 { - Kind::Standard(s) => s.char_code_to_unicode(code).map(BfString::Char), - Kind::Cff(c) => c.char_code_to_unicode(code).map(BfString::Char), - Kind::Type1(t) => t.char_code_to_unicode(code).map(BfString::Char), + let found = match &self.1 { + Kind::Standard(s) => s.char_code_to_unicode(code), + Kind::Cff(c) => c.char_code_to_unicode(code), + Kind::Type1(t) => t.char_code_to_unicode(code), + }; + if let Some(c) = found { + return Some(BfString::Char(c)); } + // PdfCraft patch (#3169, #3170): a name outside the glyph list still reads by the + // glyph list specification's full mapping (`f_i`, `hyphen.case`). A name that only + // numbers the glyph (`C39`, `c251`) says nothing about the character and reads as + // nothing: the code's StandardEncoding character would be a guess, wrong wherever the + // font's /Encoding puts the glyph elsewhere (review on #3239). + let name = match &self.1 { + Kind::Standard(s) => s.code_to_ps_name(code), + Kind::Cff(c) => c.code_to_ps_name(code), + Kind::Type1(t) => t.code_to_ps_name(code), + }?; + glyph_name_to_text(name) } } @@ -239,6 +254,14 @@ impl Type1Kind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } @@ -336,6 +359,14 @@ impl CffKind { } fn char_code_to_unicode(&self, code: u8) -> Option { + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; see + // `Encoding::text_alias`. `code_to_ps_name` is shared with glyph lookup and stays as it + // is, so the alias is applied here, after `/Differences` has had its say. + if !self.encodings.contains_key(&code) + && let Some(name) = self.encoding.text_alias(code) + { + return glyph_name_to_unicode(name); + } self.code_to_ps_name(code).and_then(glyph_name_to_unicode) } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index 6824d4ff..216f2618 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -108,6 +108,9 @@ impl<'a> Type3<'a> { .encodings .get(&code) .map(|s| s.as_str()) + // PdfCraft patch: an Annex D.2 alias code reads as the ordinary character; + // see `Encoding::text_alias`. + .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)); name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) @@ -165,9 +168,10 @@ impl<'a> Type3<'a> { is_shape_glyph }; + // PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space. let mut context = Context::new_with( state.ctm, - self.font_bbox, + root_transform.transform_rect_bbox(self.font_bbox), &glyph.cache, glyph.xref, glyph.settings.clone(), diff --git a/vendor/hayro-interpret/src/function/type0.rs b/vendor/hayro-interpret/src/function/type0.rs index 02bbc41b..a5506aaf 100644 --- a/vendor/hayro-interpret/src/function/type0.rs +++ b/vendor/hayro-interpret/src/function/type0.rs @@ -133,18 +133,12 @@ impl Type0 { let interpolated = interpolator.interpolate(&self.table)?; + // PdfCraft patch (31): `2_u32.pow(32)` overflowed (a panic with overflow checks on). + let max_sample = ((1u64 << self.bits_per_sample) - 1) as f32; let mut out = interpolated .iter() .zip(self.decode.iter()) - .map(|(x, decode)| { - interpolate( - *x, - 0.0, - (2_u32.pow(self.bits_per_sample as u32) - 1) as f32, - decode.0, - decode.1, - ) - }) + .map(|(x, decode)| interpolate(*x, 0.0, max_sample, decode.0, decode.1)) .collect::>(); self.clamper.clamp_output(&mut out); @@ -325,3 +319,24 @@ impl Key { Some(()) } } + +#[cfg(test)] +mod tests { + use crate::function::Function; + use hayro_syntax::object::{FromBytes, Object}; + use smallvec::smallvec; + + /// PdfCraft patch (31): 32-bit samples scale by `2^32 - 1` without overflowing (adapted from + /// PdfCraft's `raster::tests::sampled_functions_interpolate_their_grid_as_before`). + #[test] + fn thirty_two_bit_samples_do_not_overflow() { + let mut data = b"<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [2] /BitsPerSample 32 /Length 8 >>\nstream\n".to_vec(); + data.extend_from_slice(&[0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]); + data.extend_from_slice(b"\nendstream"); + + let object = Object::from_bytes(&data).unwrap(); + let function = Function::new(&object).unwrap(); + assert_eq!(function.eval(smallvec![0.0]).unwrap().as_slice(), &[0.0]); + assert_eq!(function.eval(smallvec![1.0]).unwrap().as_slice(), &[1.0]); + } +} diff --git a/vendor/hayro-postscript/Cargo.lock b/vendor/hayro-postscript/Cargo.lock new file mode 100644 index 00000000..b95d3415 --- /dev/null +++ b/vendor/hayro-postscript/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "hayro-postscript" +version = "0.1.0" diff --git a/vendor/hayro-postscript/Cargo.toml b/vendor/hayro-postscript/Cargo.toml new file mode 100644 index 00000000..b1d53c3f --- /dev/null +++ b/vendor/hayro-postscript/Cargo.toml @@ -0,0 +1,70 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "hayro-postscript" +version = "0.1.0" +authors = ["Laurenz Stampfl "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A lightweight PostScript scanner." +readme = "README.md" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/LaurenzV/hayro" +resolver = "2" + +[lib] +name = "hayro_postscript" +path = "src/lib.rs" + +[[example]] +name = "tokenize" +path = "examples/tokenize.rs" + +[lints.clippy] +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/hayro-postscript/Cargo.toml.orig b/vendor/hayro-postscript/Cargo.toml.orig new file mode 100644 index 00000000..6ba1eed2 --- /dev/null +++ b/vendor/hayro-postscript/Cargo.toml.orig @@ -0,0 +1,11 @@ +[package] +name = "hayro-postscript" +version = "0.1.0" +description = "A lightweight PostScript scanner." +authors = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[lints] +workspace = true diff --git a/vendor/hayro-postscript/LICENSE-APACHE b/vendor/hayro-postscript/LICENSE-APACHE new file mode 100644 index 00000000..4947287f --- /dev/null +++ b/vendor/hayro-postscript/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/vendor/hayro-postscript/LICENSE-MIT b/vendor/hayro-postscript/LICENSE-MIT new file mode 100644 index 00000000..ae3742de --- /dev/null +++ b/vendor/hayro-postscript/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/hayro-postscript/README.md b/vendor/hayro-postscript/README.md new file mode 100644 index 00000000..3c228271 --- /dev/null +++ b/vendor/hayro-postscript/README.md @@ -0,0 +1,30 @@ +# hayro-postscript + +[![Crates.io](https://img.shields.io/crates/v/hayro-postscript.svg)](https://crates.io/crates/hayro-postscript) +[![Documentation](https://docs.rs/hayro-postscript/badge.svg)](https://docs.rs/hayro-postscript) + + + +A lightweight PostScript scanner. + +This crate provides a scanner for tokenizing PostScript programs into typed objects. +It currently only implements a very small subset of the PostScript language, +with the main goal of being enough to parse CMAP files, but the scope _might_ +be expanded upon in the future. + +The supported types include integers and real numbers, name objects, strings and arrays. +Unsupported is anything else, including dictionaries, procedures, etc. An error +will be returned in case any of these is encountered. + +### Safety +This crate forbids unsafe code via a crate-level attribute. + + + +## License +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/vendor/hayro-postscript/SOURCE.json b/vendor/hayro-postscript/SOURCE.json new file mode 100644 index 00000000..2e4cc5a7 --- /dev/null +++ b/vendor/hayro-postscript/SOURCE.json @@ -0,0 +1,123 @@ +{ + "crate": "hayro-postscript", + "source_repository": "https://crates.io/api/v1/crates/hayro-postscript/0.1.0/download", + "source_revision": "0.1.0", + "source_directory": "hayro-postscript-0.1.0", + "source_archive_sha256": "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa", + "upstream_repository": "https://github.com/LaurenzV/hayro", + "upstream_revision": "a2354aebaf86f5ab13e5fd6b6b0521306488bf9d", + "upstream_directory": "hayro-postscript", + "local_patches": [ + "Skip runs of `<<` and `>>` in a loop in `object::read` instead of recursing once per delimiter, so a long run cannot overflow the stack." + ], + "files": [ + { + "path": "Cargo.lock", + "source_sha256": "9cc223b0396508805ff123d5d067bb9585cb30c1faef3de8ca61e1f6db69147c", + "source_git_blob": null, + "sha256": "9cc223b0396508805ff123d5d067bb9585cb30c1faef3de8ca61e1f6db69147c" + }, + { + "path": "Cargo.toml", + "source_sha256": "f4d3a179a58f7d2c8e4cacf86c703f7b0a84198d73dc4d215ceb19022d7f2a8e", + "source_git_blob": null, + "sha256": "f4d3a179a58f7d2c8e4cacf86c703f7b0a84198d73dc4d215ceb19022d7f2a8e" + }, + { + "path": "Cargo.toml.orig", + "source_sha256": "1020656196fae16c71159fc28291b6c7244207e783c96c29d073555b0b1986f7", + "source_git_blob": null, + "sha256": "1020656196fae16c71159fc28291b6c7244207e783c96c29d073555b0b1986f7" + }, + { + "path": "LICENSE-APACHE", + "source_sha256": "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9", + "source_git_blob": null, + "sha256": "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9" + }, + { + "path": "LICENSE-MIT", + "source_sha256": "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac", + "source_git_blob": null, + "sha256": "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac" + }, + { + "path": "README.md", + "source_sha256": "67f97360e5229700ae8c5734ececa4a4ed7bdbdb9bc6e5f0eb906cdf704c0448", + "source_git_blob": null, + "sha256": "67f97360e5229700ae8c5734ececa4a4ed7bdbdb9bc6e5f0eb906cdf704c0448" + }, + { + "path": "examples/tokenize.rs", + "source_sha256": "349a945efa6aecd04fc774e052408fad16d6e4e0a1e731e03473bbdbc7720188", + "source_git_blob": null, + "sha256": "349a945efa6aecd04fc774e052408fad16d6e4e0a1e731e03473bbdbc7720188" + }, + { + "path": "src/array.rs", + "source_sha256": "f271ba0b99889672305a39f7978eed6daf07cc112c5a6bac0f8caa329a25ebca", + "source_git_blob": null, + "sha256": "f271ba0b99889672305a39f7978eed6daf07cc112c5a6bac0f8caa329a25ebca" + }, + { + "path": "src/error.rs", + "source_sha256": "aa36f56028af56192ecb76db43bf1be42e47abc317d8cb5a5c98c926dbc41021", + "source_git_blob": null, + "sha256": "aa36f56028af56192ecb76db43bf1be42e47abc317d8cb5a5c98c926dbc41021" + }, + { + "path": "src/lib.rs", + "source_sha256": "357a6f5a549cb1a0214f8a00fe529ce0486f566fc7174c8deccd79e8c8e1f8bd", + "source_git_blob": null, + "sha256": "357a6f5a549cb1a0214f8a00fe529ce0486f566fc7174c8deccd79e8c8e1f8bd" + }, + { + "path": "src/name.rs", + "source_sha256": "045a46c014b040f26aa07155f99a9251f8e5b5a184b01d5a01b424691dd9cb54", + "source_git_blob": null, + "sha256": "045a46c014b040f26aa07155f99a9251f8e5b5a184b01d5a01b424691dd9cb54" + }, + { + "path": "src/number.rs", + "source_sha256": "ba1a75549f35616a1607acb97926aaa6be3e05a1e701a5119f9ac90d40b126e8", + "source_git_blob": null, + "sha256": "ba1a75549f35616a1607acb97926aaa6be3e05a1e701a5119f9ac90d40b126e8" + }, + { + "path": "src/object.rs", + "source_sha256": "a1c495be0fe8713ef06eafd0a4e93de50c24f7c9dde0cba9e1914a0602632791", + "source_git_blob": null, + "sha256": "a6245b15c18c21604bf1ac94dcf2fece11b09e243bb1de17392028e80c8cbae2" + }, + { + "path": "src/reader.rs", + "source_sha256": "69ef868370f3c64eff9a3c7d92ca7bc24b4b8d644dc52ea7edd6bc7a60bc4c6d", + "source_git_blob": null, + "sha256": "69ef868370f3c64eff9a3c7d92ca7bc24b4b8d644dc52ea7edd6bc7a60bc4c6d" + }, + { + "path": "src/string.rs", + "source_sha256": "1c47947beb5ce7c56036d5b115a47e703a5a276cb9f3faf5e9b2a4304e4a518f", + "source_git_blob": null, + "sha256": "1c47947beb5ce7c56036d5b115a47e703a5a276cb9f3faf5e9b2a4304e4a518f" + }, + { + "path": "src/string/ascii_85.rs", + "source_sha256": "488ec377a16b409b77135e6baf05c39774951be63f82ef69d7061322d92b16f2", + "source_git_blob": null, + "sha256": "488ec377a16b409b77135e6baf05c39774951be63f82ef69d7061322d92b16f2" + }, + { + "path": "src/string/ascii_hex.rs", + "source_sha256": "3f363f8944c4789f0b620367854355d45f9721f505f5ea3c5c20d4b2cc23aa34", + "source_git_blob": null, + "sha256": "3f363f8944c4789f0b620367854355d45f9721f505f5ea3c5c20d4b2cc23aa34" + }, + { + "path": "src/string/literal.rs", + "source_sha256": "8633efbb282227512ef506d020ea69d4ef47c549ae9248e7b0d4fcefd2aaf933", + "source_git_blob": null, + "sha256": "8633efbb282227512ef506d020ea69d4ef47c549ae9248e7b0d4fcefd2aaf933" + } + ] +} diff --git a/vendor/hayro-postscript/examples/tokenize.rs b/vendor/hayro-postscript/examples/tokenize.rs new file mode 100644 index 00000000..c8cd84d0 --- /dev/null +++ b/vendor/hayro-postscript/examples/tokenize.rs @@ -0,0 +1,74 @@ +//! A small example that shows how to tokenize a PostScript program. + +#![allow(missing_docs)] + +use hayro_postscript::{Number, Object, Scanner}; +use std::env; +use std::fs; +use std::process; + +fn main() { + let path = match env::args().nth(1) { + Some(p) => p, + None => { + eprintln!("Usage: tokenize "); + process::exit(1); + } + }; + + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) => { + eprintln!("Error reading {path}: {e}"); + process::exit(1); + } + }; + + let mut scanner = Scanner::new(&data); + + while !scanner.at_end() { + match scanner.parse_object() { + Ok(object) => { + print_object(&object); + println!(); + } + Err(e) => eprintln!("Error: {e}"), + } + } +} + +fn print_object(object: &Object<'_>) { + match object { + Object::Number(Number::Integer(n)) => print!("Integer({n})"), + Object::Number(Number::Real(n)) => print!("Real({n})"), + Object::Name(name) => { + let kind = if name.is_literal() { + "literal" + } else { + "executable" + }; + let text = name.as_str().unwrap_or(""); + print!("Name({text}, {kind})"); + } + Object::String(s) => { + let decoded = s.decode().unwrap_or_else(|_| Vec::new()); + print!("String({})", String::from_utf8_lossy(&decoded)); + } + Object::Array(arr) => { + print!("["); + let mut inner = arr.objects(); + let mut first = true; + while !inner.at_end() { + if !first { + print!(" "); + } + first = false; + match inner.parse_object() { + Ok(obj) => print_object(&obj), + Err(e) => print!("Error({e})"), + } + } + print!("]"); + } + } +} diff --git a/vendor/hayro-postscript/src/array.rs b/vendor/hayro-postscript/src/array.rs new file mode 100644 index 00000000..b96b3601 --- /dev/null +++ b/vendor/hayro-postscript/src/array.rs @@ -0,0 +1,127 @@ +use crate::error::{Error, Result}; +use crate::object; +use crate::reader::Reader; +use crate::string; + +/// A PostScript array object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Array<'a> { + data: &'a [u8], +} + +impl<'a> Array<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data } + } + + /// Return a [`Scanner`](crate::Scanner) that iterates over the objects inside + /// this array. + pub fn objects(&self) -> crate::Scanner<'a> { + crate::Scanner::new(self.data) + } +} + +pub(crate) fn parse<'a>(r: &mut Reader<'a>) -> Result<&'a [u8]> { + r.forward_tag(b"[").ok_or(Error::SyntaxError)?; + + let start = r.offset(); + skip_array(r)?; + let end = r.offset() - 1; + + r.range(start..end).ok_or(Error::SyntaxError) +} + +fn skip_array(r: &mut Reader<'_>) -> Result<()> { + let mut depth = 1_u32; + + while depth > 0 { + match r.peek_byte().ok_or(Error::SyntaxError)? { + b'[' => { + r.forward(); + depth += 1; + } + b']' => { + r.forward(); + depth -= 1; + } + b'(' => { + let _ = string::parse_literal(r).ok_or(Error::SyntaxError)?; + } + b'<' => { + if r.peek_bytes(2) == Some(b"<~") { + let _ = string::parse_ascii85(r).ok_or(Error::SyntaxError)?; + } else if r.peek_bytes(2) == Some(b"<<") { + r.forward(); + r.forward(); + } else { + let _ = string::parse_hex(r).ok_or(Error::SyntaxError)?; + } + } + b'%' => object::skip_whitespace_and_comments(r), + _ => { + r.forward(); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_array(input: &[u8]) -> Result<&[u8]> { + let mut r = Reader::new(input); + parse(&mut r) + } + + #[test] + fn empty() { + assert_eq!(parse_array(b"[]").unwrap(), b""); + } + + #[test] + fn simple() { + assert_eq!(parse_array(b"[1 2 3]").unwrap(), b"1 2 3"); + } + + #[test] + fn nested() { + assert_eq!(parse_array(b"[1 [2 3] 4]").unwrap(), b"1 [2 3] 4"); + } + + #[test] + fn with_string() { + // The ']' inside the string should not close the array. + assert_eq!(parse_array(b"[1 (str]) 2]").unwrap(), b"1 (str]) 2"); + } + + #[test] + fn with_hex_string() { + assert_eq!(parse_array(b"[<48> /name]").unwrap(), b"<48> /name"); + } + + #[test] + fn with_ascii85_string() { + assert_eq!(parse_array(b"[<~87cURDZ~> 1]").unwrap(), b"<~87cURDZ~> 1"); + } + + #[test] + fn with_comment() { + assert_eq!( + parse_array(b"[1 % comment with ]\n2]").unwrap(), + b"1 % comment with ]\n2" + ); + } + + #[test] + fn unterminated() { + assert_eq!(parse_array(b"[1 2"), Err(Error::SyntaxError)); + } + + #[test] + fn not_an_array() { + assert_eq!(parse_array(b"1 2]"), Err(Error::SyntaxError)); + } +} diff --git a/vendor/hayro-postscript/src/error.rs b/vendor/hayro-postscript/src/error.rs new file mode 100644 index 00000000..3a2627a1 --- /dev/null +++ b/vendor/hayro-postscript/src/error.rs @@ -0,0 +1,30 @@ +//! Error types for the PostScript scanner. + +use core::fmt; + +/// A specialized [`Result`] type for PostScript scanner operations. +pub type Result = core::result::Result; + +/// An error encountered while scanning a PostScript token stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// A syntax error in the input. + SyntaxError, + /// A numeric value exceeded implementation limits. + LimitCheck, + /// An unsupported PostScript type was encountered (like dictionaries or + /// procedures, which will be added in the future). + UnsupportedType, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SyntaxError => f.write_str("syntaxerror"), + Self::LimitCheck => f.write_str("limitcheck"), + Self::UnsupportedType => f.write_str("unsupported type"), + } + } +} + +impl core::error::Error for Error {} diff --git a/vendor/hayro-postscript/src/lib.rs b/vendor/hayro-postscript/src/lib.rs new file mode 100644 index 00000000..e16d5556 --- /dev/null +++ b/vendor/hayro-postscript/src/lib.rs @@ -0,0 +1,179 @@ +/*! +A lightweight PostScript scanner. + +This crate provides a scanner for tokenizing PostScript programs into typed objects. +It currently only implements a very small subset of the PostScript language, +with the main goal of being enough to parse CMAP files, but the scope _might_ +be expanded upon in the future. + +The supported types include integers and real numbers, name objects, strings and arrays. +Unsupported is anything else, including dictionaries, procedures, etc. An error +will be returned in case any of these is encountered. + +## Safety +This crate forbids unsafe code via a crate-level attribute. +*/ + +#![no_std] +#![forbid(unsafe_code)] +#![allow(missing_docs)] + +extern crate alloc; + +mod array; +mod error; +mod name; +mod number; +mod object; +mod reader; +mod string; + +pub use array::Array; +pub use error::{Error, Result}; +pub use name::Name; +pub use number::Number; +pub use object::Object; +pub use string::String; + +use reader::Reader; + +/// A PostScript scanner that parses [`Object`]s from a byte stream. +pub struct Scanner<'a> { + reader: Reader<'a>, +} + +impl<'a> Scanner<'a> { + /// Create a new scanner over the given bytes of a PostScript program. + pub fn new(data: &'a [u8]) -> Self { + Self { + reader: Reader::new(data), + } + } + + /// Returns `true` if there are no more objects to parse. + pub fn at_end(&mut self) -> bool { + object::at_end(&mut self.reader) + } + + /// Parse the next object. + pub fn parse_object(&mut self) -> Result> { + object::read(&mut self.reader) + } + + /// Parse the next object as a [`Number`]. + pub fn parse_number(&mut self) -> Result { + match self.parse_object()? { + Object::Number(n) => Ok(n), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as a [`Name`]. + pub fn parse_name(&mut self) -> Result> { + match self.parse_object()? { + Object::Name(n) => Ok(n), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as a [`String`]. + pub fn parse_string(&mut self) -> Result> { + match self.parse_object()? { + Object::String(s) => Ok(s), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as an [`Array`]. + pub fn parse_array(&mut self) -> Result> { + match self.parse_object()? { + Object::Array(a) => Ok(a), + _ => Err(Error::SyntaxError), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmap_snippet() { + let input = br#"/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CMapName /Test-H def +1 begincodespacerange +<00> +endcodespacerange +2 beginbfchar +<03> <0041> +<04> <0042> +endbfchar +endcmap"#; + + let mut s = Scanner::new(input); + + assert_eq!(s.parse_name().unwrap(), Name::new(b"CIDInit", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"ProcSet", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"findresource", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begin", false)); + assert_eq!(s.parse_number().unwrap(), Number::Integer(12)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"dict", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begin", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begincmap", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"CMapName", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"Test-H", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"def", false)); + assert_eq!(s.parse_number().unwrap(), Number::Integer(1)); + assert_eq!( + s.parse_name().unwrap(), + Name::new(b"begincodespacerange", false) + ); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"00")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"FF")); + assert_eq!( + s.parse_name().unwrap(), + Name::new(b"endcodespacerange", false) + ); + assert_eq!(s.parse_number().unwrap(), Number::Integer(2)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"beginbfchar", false)); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"03")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"0041")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"04")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"0042")); + assert_eq!(s.parse_name().unwrap(), Name::new(b"endbfchar", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"endcmap", false)); + assert!(s.at_end()); + } + + #[test] + fn array_round_trip() { + let input = b"[123 /abc (xyz)]"; + let mut scanner = Scanner::new(input); + let arr = scanner.parse_array().unwrap(); + assert!(scanner.at_end()); + + let mut inner = arr.objects(); + assert_eq!(inner.parse_number().unwrap(), Number::Integer(123)); + assert_eq!(inner.parse_name().unwrap(), Name::new(b"abc", true)); + assert_eq!(inner.parse_string().unwrap(), String::from_literal(b"xyz")); + assert!(inner.at_end()); + } + + #[test] + fn comments_skipped() { + let input = b"% comment\n42 % another\n/Name"; + let mut scanner = Scanner::new(input); + + assert_eq!(scanner.parse_number().unwrap(), Number::Integer(42)); + assert_eq!(scanner.parse_name().unwrap(), Name::new(b"Name", true)); + assert!(scanner.at_end()); + } + + #[test] + fn wrong_type_is_error() { + let mut scanner = Scanner::new(b"42 "); + assert!(scanner.parse_name().is_err()); + } +} diff --git a/vendor/hayro-postscript/src/name.rs b/vendor/hayro-postscript/src/name.rs new file mode 100644 index 00000000..ab78e7ba --- /dev/null +++ b/vendor/hayro-postscript/src/name.rs @@ -0,0 +1,191 @@ +use alloc::vec::Vec; + +use crate::error::{Error, Result}; +use crate::reader::{Reader, is_regular}; +use crate::string::ascii_hex::decode_hex_digit; + +/// A PostScript name object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Name<'a> { + data: &'a [u8], + literal: bool, +} + +impl<'a> Name<'a> { + pub(crate) fn new(data: &'a [u8], literal: bool) -> Self { + Self { data, literal } + } + + /// Returns `true` if this is a literal name. + pub fn is_literal(&self) -> bool { + self.literal + } + + /// Returns the name as a string if it is valid UTF-8, or + /// `None` if it contains non-ASCII bytes. + pub fn as_str(&self) -> Option<&'a str> { + core::str::from_utf8(self.data).ok() + } + + /// Decode the name into `out`, replacing any previous contents. + pub fn decode_into(&self, out: &mut Vec) -> Result<()> { + out.clear(); + + // Fast path: no escape sequences. + if !self.data.contains(&b'#') { + out.extend_from_slice(self.data); + return Ok(()); + } + + // Slow path: Process escape sequences. + let mut inner = Reader::new(self.data); + + while let Some(b) = inner.read_byte() { + if b == b'#' { + let hex = inner.read_bytes(2).ok_or(Error::SyntaxError)?; + let hi = decode_hex_digit(hex[0]).ok_or(Error::SyntaxError)?; + let lo = decode_hex_digit(hex[1]).ok_or(Error::SyntaxError)?; + out.push(hi << 4 | lo); + } else { + out.push(b); + } + } + + Ok(()) + } + + /// Decode the name. + pub fn decode(&self) -> Result> { + let mut out = Vec::new(); + self.decode_into(&mut out)?; + Ok(out) + } +} + +pub(crate) fn parse_literal<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"/")?; + let start = r.offset(); + while r.eat(is_regular).is_some() {} + r.range(start..r.offset()) +} + +pub(crate) fn parse_executable<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + let start = r.offset(); + r.forward_while(is_regular); + if r.offset() == start { + return None; + } + r.range(start..r.offset()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_literal(input: &[u8]) -> Option> { + let mut r = Reader::new(input); + parse_literal(&mut r).map(|d| Name::new(d, true)) + } + + fn read_executable(input: &[u8]) -> Option> { + let mut r = Reader::new(input); + parse_executable(&mut r).map(|d| Name::new(d, false)) + } + + #[test] + fn literal_simple() { + let n = read_literal(b"/Name1").unwrap(); + assert_eq!(n.as_str().unwrap(), "Name1"); + assert!(n.is_literal()); + } + + #[test] + fn literal_empty_name() { + let n = read_literal(b"/").unwrap(); + assert_eq!(n.as_str().unwrap(), ""); + assert!(n.is_literal()); + } + + #[test] + fn literal_with_hex_escape() { + let n = read_literal(b"/lime#20Green").unwrap(); + assert_eq!(n.as_str().unwrap(), "lime#20Green"); + assert_eq!(n.decode().unwrap(), b"lime Green"); + } + + #[test] + fn literal_multiple_hex_escapes() { + let n = read_literal(b"/paired#28#29parentheses").unwrap(); + assert_eq!(n.decode().unwrap(), b"paired()parentheses"); + } + + #[test] + fn literal_special_chars() { + let n = read_literal(b"/A;Name_With-Various***Characters?").unwrap(); + assert_eq!(n.as_str().unwrap(), "A;Name_With-Various***Characters?"); + } + + #[test] + fn literal_stops_at_delimiter() { + let mut r = Reader::new(b"/Name(rest"); + let data = parse_literal(&mut r).unwrap(); + assert_eq!(data, b"Name"); + assert_eq!(r.peek_byte(), Some(b'(')); + } + + #[test] + fn literal_stops_at_whitespace() { + let mut r = Reader::new(b"/Name rest"); + let data = parse_literal(&mut r).unwrap(); + assert_eq!(data, b"Name"); + assert_eq!(r.peek_byte(), Some(b' ')); + } + + #[test] + fn literal_not_a_name() { + assert!(read_literal(b"Name").is_none()); + } + + #[test] + fn executable_simple() { + let n = read_executable(b"beginbfchar ").unwrap(); + assert_eq!(n.as_str().unwrap(), "beginbfchar"); + assert!(!n.is_literal()); + } + + #[test] + fn executable_stops_at_delimiter() { + let mut r = Reader::new(b"def/name"); + let data = parse_executable(&mut r).unwrap(); + assert_eq!(data, b"def"); + assert_eq!(r.peek_byte(), Some(b'/')); + } + + #[test] + fn executable_at_eof() { + let n = read_executable(b"endcmap").unwrap(); + assert_eq!(n.as_str().unwrap(), "endcmap"); + } + + #[test] + fn executable_empty() { + assert!(read_executable(b"").is_none()); + } + + #[test] + fn executable_starts_at_delimiter() { + assert!(read_executable(b"(foo)").is_none()); + } + + #[test] + fn decode_no_escapes() { + let n = Name::new(b"simple", true); + assert_eq!(n.decode().unwrap(), b"simple"); + } + + #[test] + fn decode_with_escapes() { + let n = Name::new(b"lime#20Green", true); + assert_eq!(n.decode().unwrap(), b"lime Green"); + } +} diff --git a/vendor/hayro-postscript/src/number.rs b/vendor/hayro-postscript/src/number.rs new file mode 100644 index 00000000..5c6d3ba1 --- /dev/null +++ b/vendor/hayro-postscript/src/number.rs @@ -0,0 +1,177 @@ +use crate::error::{Error, Result}; +use crate::reader::{Reader, is_delimiter, is_whitespace}; + +/// A PostScript number object (integer or real). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Number { + Integer(i32), + Real(f32), +} + +impl Number { + /// Return the value as an `i32`. Reals are truncated. + pub fn as_i32(self) -> i32 { + match self { + Self::Integer(v) => v, + Self::Real(v) => v as i32, + } + } + + /// Return the value as an `f32`. + pub fn as_f32(self) -> f32 { + match self { + Self::Integer(v) => v as f32, + Self::Real(v) => v, + } + } + + /// Return the value as an `f64`. + pub fn as_f64(self) -> f64 { + match self { + Self::Integer(v) => v as f64, + Self::Real(v) => v as f64, + } + } +} + +fn is_terminated(r: &Reader<'_>) -> bool { + match r.peek_byte() { + None => true, + Some(b) => is_whitespace(b) || is_delimiter(b), + } +} + +pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let saved = r.offset(); + + // Optional sign. + let first = r.peek_byte().ok_or(Error::SyntaxError)?; + let has_sign = first == b'+' || first == b'-'; + + if has_sign { + r.forward(); + } + + // Consume leading digits. + let digit_start = r.offset(); + r.forward_while(|b| b.is_ascii_digit()); + let has_digits = r.offset() > digit_start; + + // Check if number is a radix number. + if !has_sign && has_digits && r.peek_byte() == Some(b'#') { + let base_bytes = r.range(digit_start..r.offset()).ok_or(Error::SyntaxError)?; + let base_str = core::str::from_utf8(base_bytes).map_err(|_| Error::SyntaxError)?; + let base = base_str.parse::().map_err(|_| Error::SyntaxError)?; + + if !(2..=36).contains(&base) { + return Err(Error::SyntaxError); + } + + // Skip `#`. + r.forward(); + + let num_start = r.offset(); + r.forward_while(|b| b.is_ascii_alphanumeric()); + + if r.offset() == num_start || !is_terminated(r) { + return Err(Error::SyntaxError); + } + + let num_bytes = r.range(num_start..r.offset()).ok_or(Error::SyntaxError)?; + let num_str = core::str::from_utf8(num_bytes).map_err(|_| Error::SyntaxError)?; + let value = i32::from_str_radix(num_str, base).map_err(|_| Error::SyntaxError)?; + + return Ok(Number::Integer(value)); + } + + // Check for real number indicators: `.` or `e`/`E`. + let has_dot = r.peek_byte() == Some(b'.'); + + if has_dot { + r.forward(); // skip '.' + r.forward_while(|b| b.is_ascii_digit()); + } + + // At this point we need at least some digits (before or after the dot). + if !has_digits && !has_dot { + return Err(Error::SyntaxError); + } + + let has_exponent = matches!(r.peek_byte(), Some(b'e' | b'E')); + if has_exponent { + r.forward(); + + // Optional exponent sign. + if matches!(r.peek_byte(), Some(b'+' | b'-')) { + r.forward(); + } + + r.forward_while(|b| b.is_ascii_digit()); + } + + if !is_terminated(r) { + return Err(Error::SyntaxError); + } + + let token = r.range(saved..r.offset()).ok_or(Error::SyntaxError)?; + let str = core::str::from_utf8(token).map_err(|_| Error::SyntaxError)?; + + if has_dot || has_exponent { + let value = str.parse::().map_err(|_| Error::SyntaxError)?; + + Ok(Number::Real(value)) + } else { + if !has_digits { + return Err(Error::SyntaxError); + } + + let value = str.parse::().map_err(|_| Error::SyntaxError)?; + + Ok(Number::Integer(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_num(input: &[u8]) -> Result { + let mut r = Reader::new(input); + read(&mut r) + } + + #[test] + fn signed_integers() { + assert_eq!(read_num(b"123 ").unwrap(), Number::Integer(123)); + assert_eq!(read_num(b"-98 ").unwrap(), Number::Integer(-98)); + assert_eq!(read_num(b"43445 ").unwrap(), Number::Integer(43445)); + assert_eq!(read_num(b"0 ").unwrap(), Number::Integer(0)); + assert_eq!(read_num(b"+17 ").unwrap(), Number::Integer(17)); + } + + #[test] + fn real_numbers() { + assert_eq!(read_num(b"-.002 ").unwrap(), Number::Real(-0.002)); + assert_eq!(read_num(b"34.5 ").unwrap(), Number::Real(34.5)); + assert_eq!(read_num(b"-3.62 ").unwrap(), Number::Real(-3.62)); + assert_eq!(read_num(b"123.6e10 ").unwrap(), Number::Real(123.6e10)); + assert_eq!(read_num(b"1.0E-5 ").unwrap(), Number::Real(1.0E-5)); + assert_eq!(read_num(b"1E6 ").unwrap(), Number::Real(1E6)); + assert_eq!(read_num(b"-1. ").unwrap(), Number::Real(-1.0)); + assert_eq!(read_num(b"0.0 ").unwrap(), Number::Real(0.0)); + } + + #[test] + fn radix_numbers() { + assert_eq!(read_num(b"8#1777 ").unwrap(), Number::Integer(0o1777)); + assert_eq!(read_num(b"16#FFFE ").unwrap(), Number::Integer(0xFFFE)); + assert_eq!(read_num(b"2#1000 ").unwrap(), Number::Integer(0b1000)); + } + + #[test] + fn invalid() { + assert!(read_num(b"abc").is_err()); + assert!(read_num(b"+abc").is_err()); + assert!(read_num(b"1a").is_err()); + } +} diff --git a/vendor/hayro-postscript/src/object.rs b/vendor/hayro-postscript/src/object.rs new file mode 100644 index 00000000..7c200758 --- /dev/null +++ b/vendor/hayro-postscript/src/object.rs @@ -0,0 +1,226 @@ +use crate::array::{self, Array}; +use crate::error::{Error, Result}; +use crate::name::{self, Name}; +use crate::number::{self, Number}; +use crate::reader::Reader; +use crate::string::{self, String}; + +/// A PostScript object. +#[derive(Debug, Clone, PartialEq)] +pub enum Object<'a> { + /// A number object. + Number(Number), + /// A name object. + Name(Name<'a>), + /// A string object. + String(String<'a>), + /// An array object. + Array(Array<'a>), +} + +pub(crate) fn read<'a>(r: &mut Reader<'a>) -> Result> { + // PdfCraft patch: `<<` and `>>` are skipped by looping instead of + // recursing once per delimiter, so a long run cannot overflow the stack. + loop { + skip_whitespace_and_comments(r); + + let b = r.peek_byte().ok_or(Error::SyntaxError)?; + + return match b { + b'(' => string::parse_literal(r) + .map(|s| Object::String(String::from_literal(s))) + .ok_or(Error::SyntaxError), + b'<' => { + if r.peek_bytes(2) == Some(b"<~") { + string::parse_ascii85(r) + .map(|s| Object::String(String::from_ascii85(s))) + .ok_or(Error::SyntaxError) + } else if r.peek_bytes(2) == Some(b"<<") { + // TODO: Proper dict support. For now, skip `<<` and return + // the next object so callers see the inner tokens. + r.forward(); + r.forward(); + continue; + } else { + string::parse_hex(r) + .map(|s| Object::String(String::from_hex(s))) + .ok_or(Error::SyntaxError) + } + } + b'/' => name::parse_literal(r) + .map(|s| Object::Name(Name::new(s, true))) + .ok_or(Error::SyntaxError), + b'[' => array::parse(r).map(|d| Object::Array(Array::new(d))), + b'>' => { + if r.peek_bytes(2) == Some(b">>") { + // TODO: Proper dict support. Skip `>>` closing delimiter. + r.forward(); + r.forward(); + continue; + } else { + Err(Error::SyntaxError) + } + } + b'{' => { + r.forward(); + Err(Error::UnsupportedType) + } + b'.' | b'+' | b'-' | b'0'..=b'9' => number::read(r).map(Object::Number), + _ => name::parse_executable(r) + .map(|s| Object::Name(Name::new(s, false))) + .ok_or(Error::SyntaxError), + }; + } +} + +pub(crate) fn at_end(r: &mut Reader<'_>) -> bool { + skip_whitespace_and_comments(r); + r.peek_byte().is_none() +} + +pub(crate) fn skip_whitespace_and_comments(r: &mut Reader<'_>) { + loop { + match r.peek_byte() { + Some(b) if crate::reader::is_whitespace(b) => { + r.forward(); + } + Some(b'%') => { + r.forward(); + r.forward_while(|b| !crate::reader::is_eol(b)); + } + _ => return, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_one(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + read(&mut r) + } + + fn read_ok(input: &[u8]) -> Object<'_> { + read_one(input).unwrap() + } + + fn read_err(input: &[u8]) -> Error { + read_one(input).unwrap_err() + } + + #[test] + fn integer() { + assert_eq!(read_ok(b"42 "), Object::Number(Number::Integer(42))); + } + + #[test] + fn negative_integer() { + assert_eq!(read_ok(b"-7 "), Object::Number(Number::Integer(-7))); + } + + #[test] + fn literal_name() { + assert_eq!( + read_ok(b"/CMapName "), + Object::Name(Name::new(b"CMapName", true)) + ); + } + + #[test] + fn executable_name() { + let obj = read_ok(b"beginbfchar "); + assert_eq!(obj, Object::Name(Name::new(b"beginbfchar", false))); + } + + #[test] + fn literal_string() { + assert_eq!( + read_ok(b"(Hello)"), + Object::String(String::from_literal(b"Hello")) + ); + } + + #[test] + fn hex_string() { + assert_eq!( + read_ok(b"<48656C6C6F>"), + Object::String(String::from_hex(b"48656C6C6F")) + ); + } + + #[test] + fn ascii85_string() { + assert_eq!( + read_ok(b"<~87cURDZ~>"), + Object::String(String::from_ascii85(b"87cURDZ")) + ); + } + + #[test] + fn array_simple() { + let obj = read_ok(b"[1 2 3]"); + assert_eq!(obj, Object::Array(Array::new(b"1 2 3"))); + } + + #[test] + fn stray_close_bracket() { + assert_eq!(read_err(b"]"), Error::SyntaxError); + } + + #[test] + fn stray_gt() { + assert_eq!(read_err(b">x"), Error::SyntaxError); + } + + #[test] + fn skips_whitespace() { + assert_eq!(read_ok(b" \t\n 42 "), Object::Number(Number::Integer(42))); + } + + #[test] + fn skips_comments() { + assert_eq!( + read_ok(b"% this is a comment\n42 "), + Object::Number(Number::Integer(42)) + ); + } + + #[test] + fn eof() { + assert!(read_one(b"").is_err()); + assert!(read_one(b" ").is_err()); + assert!(read_one(b"% comment only\n").is_err()); + } + + // PdfCraft patch: `<<` and `>>` runs are skipped without recursion. + #[test] + fn long_open_dict_run_does_not_overflow() { + let input = alloc::vec![b'<'; 1_000_000]; + assert_eq!(read_err(&input), Error::SyntaxError); + } + + #[test] + fn long_close_dict_run_does_not_overflow() { + let input = b">>".repeat(500_000); + assert_eq!(read_err(&input), Error::SyntaxError); + } + + #[test] + fn dict_delimiters_are_skipped() { + let mut r = Reader::new(b"<< /A 1 >>"); + assert_eq!(read(&mut r), Ok(Object::Name(Name::new(b"A", true)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(1)))); + assert_eq!(read(&mut r), Err(Error::SyntaxError)); + } + + #[test] + fn nested_dict_delimiters_are_skipped() { + let mut r = Reader::new(b"<<<< /X >>>> 7 <<\n% c\n<< >> 8"); + assert_eq!(read(&mut r), Ok(Object::Name(Name::new(b"X", true)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(7)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(8)))); + assert!(at_end(&mut r)); + } +} diff --git a/vendor/hayro-postscript/src/reader.rs b/vendor/hayro-postscript/src/reader.rs new file mode 100644 index 00000000..f8a49674 --- /dev/null +++ b/vendor/hayro-postscript/src/reader.rs @@ -0,0 +1,136 @@ +use core::ops::Range; + +#[derive(Clone, Debug)] +pub(crate) struct Reader<'a> { + pub(crate) data: &'a [u8], + pub(crate) offset: usize, +} + +impl<'a> Reader<'a> { + #[inline] + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + #[inline] + pub(crate) fn jump(&mut self, offset: usize) { + self.offset = offset; + } + + #[inline] + pub(crate) fn range(&self, range: Range) -> Option<&'a [u8]> { + self.data.get(range) + } + + #[inline] + pub(crate) fn offset(&self) -> usize { + self.offset + } + + #[inline] + pub(crate) fn read_bytes(&mut self, len: usize) -> Option<&'a [u8]> { + let v = self.peek_bytes(len)?; + self.offset += len; + Some(v) + } + + #[inline] + pub(crate) fn read_byte(&mut self) -> Option { + let v = self.peek_byte()?; + self.offset += 1; + Some(v) + } + + #[inline] + pub(crate) fn peek_bytes(&self, len: usize) -> Option<&'a [u8]> { + self.data.get(self.offset..self.offset + len) + } + + #[inline] + pub(crate) fn peek_byte(&self) -> Option { + self.data.get(self.offset).copied() + } + + #[inline] + pub(crate) fn eat(&mut self, f: impl Fn(u8) -> bool) -> Option { + let val = self.peek_byte()?; + if f(val) { + self.forward(); + Some(val) + } else { + None + } + } + + #[inline] + pub(crate) fn forward(&mut self) { + self.offset += 1; + } + + #[inline] + pub(crate) fn forward_while(&mut self, f: impl Fn(u8) -> bool) { + while let Some(b) = self.peek_byte() { + if f(b) { + self.forward(); + } else { + break; + } + } + } + + #[inline] + pub(crate) fn forward_tag(&mut self, tag: &[u8]) -> Option<()> { + self.peek_tag(tag)?; + self.offset += tag.len(); + Some(()) + } + + #[inline] + pub(crate) fn peek_tag(&self, tag: &[u8]) -> Option<()> { + let mut cloned = self.clone(); + + for b in tag.iter().copied() { + if cloned.peek_byte() == Some(b) { + cloned.forward(); + } else { + return None; + } + } + + Some(()) + } + + #[inline] + pub(crate) fn skip_eol(&mut self) { + while let Some(b) = self.peek_byte() { + if is_eol(b) { + self.forward(); + } else { + return; + } + } + } +} + +#[inline(always)] +pub(crate) fn is_whitespace(b: u8) -> bool { + matches!(b, 0x00 | 0x09 | 0x0a | 0x0c | 0x0d | 0x20) +} + +#[inline(always)] +pub(crate) fn is_delimiter(b: u8) -> bool { + matches!( + b, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +#[inline(always)] +pub(crate) fn is_regular(b: u8) -> bool { + !is_whitespace(b) && !is_delimiter(b) +} + +#[inline(always)] +pub(crate) fn is_eol(b: u8) -> bool { + matches!(b, 0x0a | 0x0d) +} diff --git a/vendor/hayro-postscript/src/string.rs b/vendor/hayro-postscript/src/string.rs new file mode 100644 index 00000000..ed39d57c --- /dev/null +++ b/vendor/hayro-postscript/src/string.rs @@ -0,0 +1,288 @@ +mod ascii_85; +pub(crate) mod ascii_hex; +mod literal; + +use alloc::vec::Vec; + +use crate::error::{Error, Result}; +use crate::reader::Reader; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StringInner<'a> { + Literal(&'a [u8]), + Hex(&'a [u8]), + Ascii85(&'a [u8]), +} + +/// A PostScript string object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct String<'a> { + inner: StringInner<'a>, +} + +impl<'a> String<'a> { + pub(crate) const fn from_literal(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Literal(data), + } + } + + pub(crate) const fn from_hex(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Hex(data), + } + } + + pub(crate) const fn from_ascii85(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Ascii85(data), + } + } + + /// Decode the string content into `out`, replacing any previous contents. + pub fn decode_into(&self, out: &mut Vec) -> Result<()> { + out.clear(); + match self.inner { + StringInner::Literal(data) => literal::decode_into(data, out), + StringInner::Hex(data) => ascii_hex::decode_into(data, out), + StringInner::Ascii85(data) => ascii_85::decode_into(data, out), + } + .ok_or(Error::SyntaxError) + } + + /// Decode the string content. + pub fn decode(&self) -> Result> { + let mut out = Vec::new(); + self.decode_into(&mut out)?; + Ok(out) + } +} + +pub(crate) fn parse_literal<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + let start = r.offset(); + skip_literal(r)?; + let end = r.offset(); + // Exclude outer parentheses. + r.range(start + 1..end - 1) +} + +pub(crate) fn parse_hex<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"<")?; + let start = r.offset(); + while let Some(b) = r.read_byte() { + if b == b'>' { + return r.range(start..r.offset() - 1); + } + } + None +} + +pub(crate) fn parse_ascii85<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"<~")?; + let start = r.offset(); + loop { + let b = r.read_byte()?; + if b == b'~' { + let end = r.offset() - 1; + r.forward_tag(b">")?; + return r.range(start..end); + } + } +} + +fn skip_literal(r: &mut Reader<'_>) -> Option<()> { + r.forward_tag(b"(")?; + let mut depth = 1_u32; + + while depth > 0 { + let byte = r.read_byte()?; + match byte { + b'\\' => { + let _ = r.read_byte()?; + } + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_literal(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_literal(&mut r).ok_or(Error::SyntaxError)?; + String::from_literal(data).decode() + } + + #[test] + fn literal_empty() { + assert_eq!(decode_literal(b"()").unwrap(), b""); + } + + #[test] + fn literal_simple() { + assert_eq!(decode_literal(b"(Hello)").unwrap(), b"Hello"); + } + + #[test] + fn literal_nested_parens() { + assert_eq!( + decode_literal(b"(Hi (()) there)").unwrap(), + b"Hi (()) there" + ); + } + + #[test] + fn literal_escape_n() { + assert_eq!(decode_literal(b"(a\\nb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_escape_r() { + assert_eq!(decode_literal(b"(a\\rb)").unwrap(), b"a\rb"); + } + + #[test] + fn literal_escape_t() { + assert_eq!(decode_literal(b"(a\\tb)").unwrap(), b"a\tb"); + } + + #[test] + fn literal_escape_b() { + assert_eq!(decode_literal(b"(a\\bb)").unwrap(), &[b'a', 0x08, b'b']); + } + + #[test] + fn literal_escape_f() { + assert_eq!(decode_literal(b"(a\\fb)").unwrap(), &[b'a', 0x0C, b'b']); + } + + #[test] + fn literal_escape_backslash() { + assert_eq!(decode_literal(b"(a\\\\b)").unwrap(), b"a\\b"); + } + + #[test] + fn literal_escape_parens() { + assert_eq!(decode_literal(b"(Hi \\()").unwrap(), b"Hi ("); + } + + #[test] + fn literal_octal_three_digits() { + assert_eq!(decode_literal(b"(\\053)").unwrap(), b"+"); + } + + #[test] + fn literal_octal_two_digits() { + assert_eq!(decode_literal(b"(\\36)").unwrap(), b"\x1e"); + } + + #[test] + fn literal_octal_one_digit() { + assert_eq!(decode_literal(b"(\\3)").unwrap(), b"\x03"); + } + + #[test] + fn literal_line_continuation_lf() { + assert_eq!(decode_literal(b"(Hi \\\nthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_line_continuation_cr() { + assert_eq!(decode_literal(b"(Hi \\\rthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_line_continuation_crlf() { + assert_eq!(decode_literal(b"(Hi \\\r\nthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_bare_eol_lf() { + assert_eq!(decode_literal(b"(a\nb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_bare_eol_cr() { + assert_eq!(decode_literal(b"(a\rb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_bare_eol_crlf() { + assert_eq!(decode_literal(b"(a\r\nb)").unwrap(), b"a\nb"); + } + + fn decode_hex(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_hex(&mut r).ok_or(Error::SyntaxError)?; + String::from_hex(data).decode() + } + + #[test] + fn hex_simple() { + assert_eq!(decode_hex(b"<48656C6C6F>").unwrap(), b"Hello"); + } + + #[test] + fn hex_with_whitespace() { + assert_eq!(decode_hex(b"<48 65 6C 6C 6F>").unwrap(), b"Hello"); + } + + #[test] + fn hex_odd_nibble() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0xC0]); + } + + #[test] + fn hex_empty() { + assert_eq!(decode_hex(b"<>").unwrap(), b""); + } + + #[test] + fn hex_lowercase() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0xCD]); + } + + #[test] + fn hex_mixed_case() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0x3E]); + } + + fn decode_a85(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_ascii85(&mut r).ok_or(Error::SyntaxError)?; + String::from_ascii85(data).decode() + } + + #[test] + fn ascii85_simple() { + // "Hello" in ASCII85 is "87cURDZ" + assert_eq!(decode_a85(b"<~87cURDZ~>").unwrap(), b"Hello"); + } + + #[test] + fn ascii85_empty() { + assert_eq!(decode_a85(b"<~~>").unwrap(), b""); + } + + #[test] + fn ascii85_z_shorthand() { + assert_eq!(decode_a85(b"<~z~>").unwrap(), &[0, 0, 0, 0]); + } + + #[test] + fn ascii85_partial_group() { + let result = decode_a85(b"<~87~>").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn ascii85_with_whitespace() { + assert_eq!(decode_a85(b"<~87cU RDZ~>").unwrap(), b"Hello"); + } +} diff --git a/vendor/hayro-postscript/src/string/ascii_85.rs b/vendor/hayro-postscript/src/string/ascii_85.rs new file mode 100644 index 00000000..93f7f502 --- /dev/null +++ b/vendor/hayro-postscript/src/string/ascii_85.rs @@ -0,0 +1,90 @@ +// Keep in sync with `hayro-syntax/src/filter/ascii_85.rs`. + +use crate::reader::{Reader, is_whitespace}; +use alloc::vec::Vec; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; + + let mut reader = Reader::new(data); + + let mut read_byte = || -> Option { + loop { + let b = reader.read_byte()?; + + // White space characters should be ignored. + if !is_whitespace(b) { + return Some(b); + } + } + }; + + let flush_group = |group: &mut Vec, decoded: &mut Vec| -> Option<()> { + let (digits, output_len): ([u32; 5], usize) = match group.len() { + 0 => return Some(()), + 1 => return None, // A single character is not valid. + 2 => ( + [group[0], group[1], b'u', b'u', b'u'].map(|b| (b - b'!') as u32), + 1, + ), + 3 => ( + [group[0], group[1], group[2], b'u', b'u'].map(|b| (b - b'!') as u32), + 2, + ), + 4 => ( + [group[0], group[1], group[2], group[3], b'u'].map(|b| (b - b'!') as u32), + 3, + ), + 5 => ( + [group[0], group[1], group[2], group[3], group[4]].map(|b| (b - b'!') as u32), + 4, + ), + _ => unreachable!(), + }; + + let value = digits[0] + .checked_mul(POW_85[0])? + .checked_add(digits[1].checked_mul(POW_85[1])?)? + .checked_add(digits[2].checked_mul(POW_85[2])?)? + .checked_add(digits[3].checked_mul(POW_85[3])?)? + .checked_add(digits[4])?; + + decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + group.clear(); + Some(()) + }; + + out.reserve(data.len() * 4 / 5); + let mut group = Vec::with_capacity(5); + + loop { + let Some(b) = read_byte() else { + // Be lenient and accept what we have (see PDFBOX-5910). + flush_group(&mut group, out)?; + + return Some(()); + }; + + match b { + b'!'..=b'u' => { + group.push(b); + + if group.len() == 5 { + flush_group(&mut group, out)?; + } + } + b'z' => { + flush_group(&mut group, out)?; + out.extend_from_slice(&[0, 0, 0, 0]); + } + b'~' => { + // Technically requires a '>', but there is a PDF where it isn't + // appended and decodes fine in other viewers. + flush_group(&mut group, out)?; + + return Some(()); + } + _ => return None, // Invalid character. + } + } +} diff --git a/vendor/hayro-postscript/src/string/ascii_hex.rs b/vendor/hayro-postscript/src/string/ascii_hex.rs new file mode 100644 index 00000000..bdf74389 --- /dev/null +++ b/vendor/hayro-postscript/src/string/ascii_hex.rs @@ -0,0 +1,59 @@ +// Keep in sync with `hayro-syntax/src/filter/ascii_hex.rs`. + +use crate::reader::is_whitespace; +use alloc::vec::Vec; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + let has_whitespace = data.iter().any(|&b| is_whitespace(b)); + + out.reserve(data.len().div_ceil(2)); + + if !has_whitespace { + // Fast path, don't need to worry about white spaces. + let mut i = 0; + while i + 1 < data.len() { + out.push(decode_hex_digit(data[i])? << 4 | decode_hex_digit(data[i + 1])?); + i += 2; + } + if i < data.len() { + out.push(decode_hex_digit(data[i])? << 4); + } + } else { + // Slow path, need to strip white spaces. + let mut iter = data.iter().copied(); + let mut read_byte = || -> Option { + loop { + let b = iter.next()?; + if !is_whitespace(b) { + return Some(b); + } + } + }; + + loop { + match (read_byte(), read_byte()) { + (Some(hi), Some(lo)) => { + out.push(decode_hex_digit(hi)? << 4 | decode_hex_digit(lo)?); + } + (Some(hi), None) => { + out.push(decode_hex_digit(hi)? << 4); + + break; + } + (None, _) => break, + } + } + } + + Some(()) +} + +#[inline(always)] +pub(crate) fn decode_hex_digit(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'A'..=b'F' => Some(c - b'A' + 10), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } +} diff --git a/vendor/hayro-postscript/src/string/literal.rs b/vendor/hayro-postscript/src/string/literal.rs new file mode 100644 index 00000000..37a15934 --- /dev/null +++ b/vendor/hayro-postscript/src/string/literal.rs @@ -0,0 +1,85 @@ +// Keep in sync with `hayro-syntax/src/object/string.rs` (`read_literal`). + +use alloc::vec::Vec; + +use crate::reader::Reader; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + let mut r = Reader::new(data); + + while let Some(byte) = r.read_byte() { + match byte { + b'\\' => { + let next = r.read_byte()?; + + if is_octal_digit(next) { + let second = r.read_byte(); + let third = r.read_byte(); + + let bytes = match (second, third) { + (Some(n1), Some(n2)) => match (is_octal_digit(n1), is_octal_digit(n2)) { + (true, true) => [next, n1, n2], + (true, _) => { + r.jump(r.offset() - 1); + [b'0', next, n1] + } + _ => { + r.jump(r.offset() - 2); + [b'0', b'0', next] + } + }, + (Some(n1), None) => { + if is_octal_digit(n1) { + [b'0', next, n1] + } else { + r.jump(r.offset() - 1); + [b'0', b'0', next] + } + } + _ => [b'0', b'0', next], + }; + + let str = core::str::from_utf8(&bytes).unwrap(); + + if let Ok(num) = u8::from_str_radix(str, 8) { + out.push(num); + } + } else { + match next { + b'n' => out.push(0xA), + b'r' => out.push(0xD), + b't' => out.push(0x9), + b'b' => out.push(0x8), + b'f' => out.push(0xC), + b'(' => out.push(b'('), + b')' => out.push(b')'), + b'\\' => out.push(b'\\'), + b'\n' | b'\r' => { + // "If the \ is followed immediately by a newline + // (CR, LF, or CR-LF pair), the scanner ignores + // both the initial \ and the newline; this breaks + // a string into multiple lines without including + // the newline character as part of the string." + r.skip_eol(); + } + _ => out.push(next), + } + } + } + b'(' | b')' => out.push(byte), + // "But if a newline appears without a preceding \, the result is + // equivalent to \n." + b'\n' | b'\r' => { + out.push(b'\n'); + r.skip_eol(); + } + other => out.push(other), + } + } + + Some(()) +} + +fn is_octal_digit(byte: u8) -> bool { + matches!(byte, b'0'..=b'7') +} diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 800895ae..0d41b262 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -8,7 +8,7 @@ "path": "Cargo.lock", "source_sha256": "d73da432072780b3d1fc725da68ad174c75f1720d0ed903521f664bfc16c70a8", "source_git_blob": "ebc07dfff7b53f62a5ff422c19a779ab61c106cc", - "sha256": "d73da432072780b3d1fc725da68ad174c75f1720d0ed903521f664bfc16c70a8" + "sha256": "6106b3f3ad57258826fa72d2eaebfd2f66a37fccdc67507c348e603f71db088d" }, { "path": "Cargo.toml", @@ -68,7 +68,7 @@ "path": "src/content/mod.rs", "source_sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0", "source_git_blob": "8e47324d0b0bd90ed15efe5ff1e9de0cb46e75d9", - "sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0" + "sha256": "aa0883ba92dd05655bfd8d54195584bab2c0b28111d76ec73c5381a0f23b6181" }, { "path": "src/content/ops.rs", @@ -134,7 +134,7 @@ "path": "src/filter/ascii_85.rs", "source_sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24", "source_git_blob": "5a18cf8352d7d5a1770b4ea3068254187ff4b4f9", - "sha256": "df32c6783b1c1969f9b6b03de3bf204384dfdba767f7beefdd8baa8590387d24" + "sha256": "054c13b992fd2c4a1a2d9881198eadb82d8c8454666d136b3c2dfaea60f7308a" }, { "path": "src/filter/ascii_hex.rs", @@ -146,7 +146,7 @@ "path": "src/filter/ccitt.rs", "source_sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf", "source_git_blob": "98924f2dbdd942bc6ee64f66749b8d2496d8b45e", - "sha256": "101e2e7e69ece280a258edcadd29f5ea0442ce6822cf1dd1906dca41a69436bf" + "sha256": "b0fef2271417c3009a1de569c9573b05dd464ff76c1a5f7220f634bb5feb44e2" }, { "path": "src/filter/dct.rs", @@ -176,7 +176,7 @@ "path": "src/filter/mod.rs", "source_sha256": "5e6d62da5b1fd023f85f79bc8ade928b508214e13668f9fc99f3a08bc4d48a15", "source_git_blob": "22bebf6d3287f1dc893c67b76a139108b28a8188", - "sha256": "8cc6c2572519046bd27adfc597630045e453829a954eaf1747fee7a447e5c5cb" + "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" }, { "path": "src/filter/run_length.rs", @@ -260,7 +260,7 @@ "path": "src/object/number.rs", "source_sha256": "4bc2df257241ccb66450b7176bcab20d780bc9e22469cec588ebee5fac272905", "source_git_blob": "bd37b19c3053317dabbfa38d3473618631e6b669", - "sha256": "85598a27cf730aa9d2fe6a7094a2b375e67c4fed588de86b47cb8ebf38a702fa" + "sha256": "f5a6a10e80abd84395edc34ceae43887fc6bc8941c8f0e26799cc4754f6b1543" }, { "path": "src/object/rect.rs", @@ -278,7 +278,7 @@ "path": "src/object/stream.rs", "source_sha256": "151721c7bfe558a6942aba323d3185cf6db5bf726bd905326881c70ef480240f", "source_git_blob": "833610817b4738304a7376cd43ea079cb352bd8c", - "sha256": "e83d7ecb01a038d5a0ae589d77e877f9521097010e5f88db8f8a333fd161aff1" + "sha256": "05928b3008cd06098220ee7cdfd03ebc6351c205d947491be37c77cd091dc78e" }, { "path": "src/object/string.rs", @@ -296,7 +296,7 @@ "path": "src/page.rs", "source_sha256": "19d0e7604a3a692956e523128022d07944253b308793598795e09595cb04e79e", "source_git_blob": "7d63931a324f5faf90bb37e0a90670643567dace", - "sha256": "681681016eda0d1cdbec10d2cb0305c5f439724ec5d0b3be218a18e99b5e72b8" + "sha256": "e39c9888ff5d1a6e6565d1e9fc810b2e4b285c9a014e5b887080a8839c7d5490" }, { "path": "src/pdf.rs", @@ -338,7 +338,7 @@ "path": "src/xref.rs", "source_sha256": "51ffeeb7d4ac035bae2cd5ba9288c180088ae06cdcefe2b29dfeda560a793aa0", "source_git_blob": "20e33a410489d42e438a3f2b87d70c7bb8d66eae", - "sha256": "411ce7e3c0d385b1735f945b92dba164825456ef3b70ef464279ba0bac0879d4" + "sha256": "fa8d24280df15ffe5628057fdcfa2a3d2a9f42539b6f9096b2feb836ac54ac30" }, { "path": "src/decode_limits_tests.rs", diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 8e47324d..ee749cd9 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -40,7 +40,7 @@ use crate::object::name::{Name, skip_name_like}; use crate::object::{Array, Null, Number, Object, Stream}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; -use crate::trivia::{Comment, is_white_space_character}; +use crate::trivia::{Comment, is_regular_character, is_white_space_character}; use crate::util::find_needle; use core::array; use core::fmt::{Debug, Formatter}; @@ -246,11 +246,12 @@ impl<'a> UntypedIter<'a> { self.reader.read_bytes(pos)?; if self.reader.peek_bytes(2) == Some(b"EI") { - // If the following character is not a whitespace character, then we are in a ASCII-85 stream. + // PdfCraft patch: if the following character is a regular character, + // then the token is not `EI` (white space and delimiters both end it). if self .reader .peek_bytes(3) - .is_some_and(|b| !is_white_space_character(b[2])) + .is_some_and(|b| is_regular_character(b[2])) { self.reader.read_bytes(3)?; @@ -299,16 +300,21 @@ impl<'a> UntypedIter<'a> { find_reader.read_bytes(next_pos)?; if is_ei { - let analyze_data = &tail[..find_reader.offset()]; + // PdfCraft patch: a byte above 0x7F between the two "EI"s + // used to reject the candidate ("binary data in-between, so + // we for sure have not reached the end"). When the candidate + // is the real end, those bytes are the content stream that + // follows the image, and a content stream carries bytes above + // 0x7F inside its strings: WinAnsi text such as `(Grüße) Tj`, + // or the two-byte codes of any Type 0 font. A page whose text + // held one, with a capital "EI" in a later word (EINGANG, + // BEI, KLEIN), had no valid candidate left, so the image data + // ran to the end of the stream and nothing after the image + // was drawn or extracted. The operator check below is the + // discriminator, and `MAX_EI_CANDIDATES` already bounds how + // often it runs. - // If there is any binary data in-between, we for sure - // have not reached the end. - if analyze_data.iter().any(|c| !c.is_ascii()) { - self.reader.read_bytes(2)?; - continue 'outer; - } - - // Otherwise, the only possibility that we reached an + // The only possibility that we reached an // "EI", even though the previous one was valid, is // that it's part of a string in the content // stream that follows the inline image. Therefore, @@ -872,6 +878,67 @@ mod tests { assert_eq!(operators(data), [b"TJ".to_vec(), b"BI".to_vec(), b"q".to_vec(), b"Q".to_vec()]); } + /// PdfCraft patch: bytes above 0x7F after an `EI` candidate don't reject it. + #[test] + fn an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte() { + // `(Grüße)` in WinAnsi, then a word holding a capital "EI": both are everyday content, + // and the look-ahead used to read the non-ASCII bytes of the string as image data. + let data = b"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI BT /F1 12 Tf (Gr\xFC\xDFe) Tj (EINGANG) Tj ET"; + + assert_eq!( + operators(data), + [ + b"BI".to_vec(), + b"BT".to_vec(), + b"Tf".to_vec(), + b"Tj".to_vec(), + b"Tj".to_vec(), + b"ET".to_vec() + ] + ); + } + + /// PdfCraft patch: `EI` followed by a delimiter ends the image. + #[test] + fn inline_image_ei_may_be_followed_by_a_delimiter() { + let mut image = b"BI /W 16 /H 4 /BPC 8 /CS /G ID ".to_vec(); + image.extend([0x80; 64]); + image.extend(b"\nEI"); + let tail = b" Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"; + let ops = |suffix: &[u8]| { + let mut data = image.clone(); + data.extend(suffix); + data.extend(tail); + operators(&data) + }; + let control = ops(b""); + assert_eq!(control.len(), 7); + assert_eq!(ops(b"%c\n"), control); + assert_eq!(ops(b"[] 0 d"), [&control[..1], &[b"d".to_vec()], &control[1..]].concat()); + } + + #[test] + fn inline_image_ei_followed_by_a_regular_character_is_not_the_end() { + let data = b"BI /W 1 /H 1 /BPC 8 /CS /G ID \x80 EIQ \x81 EI Q BT (a) Tj ET"; + // `EIQ` is one token and stays part of the image data. + let expected: Vec> = + ["BI", "Q", "BT", "Tj", "ET"].iter().map(|s| s.as_bytes().to_vec()).collect(); + assert_eq!(operators(data), expected); + } + + #[test] + fn ascii85_inline_image_data_may_contain_ei_before_a_delimiter() { + for glue in [&b"EI["[..], b"EI/", b"EI%", b"EI(", b"EI<"] { + let mut data = b"BI /W 4 /H 4 /BPC 8 /CS /G /F /A85 ID 9jqo^".to_vec(); + data.extend(glue); + data.extend(b"87cURD]i,\"Ebo80~>EI Q BT /F1 12 Tf (AFTER) Tj ET"); + let expected: Vec> = + ["BI", "Q", "BT", "Tf", "Tj", "ET"].iter().map(|s| s.as_bytes().to_vec()).collect(); + assert_eq!(operators(&data), expected, "{}", String::from_utf8_lossy(glue)); + } + } + + #[test] fn tokenizer_interface_preserves_every_byte_boundary() { let data = b"% comment\n1 -2.5 /Name /A#20B (a\\(b\\) \\101) <4869> [1 2] << /Key (value) >> [(a) 1] TJ BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI q Q"; diff --git a/vendor/hayro-syntax/src/filter/ascii_85.rs b/vendor/hayro-syntax/src/filter/ascii_85.rs index 5a18cf83..b7199365 100644 --- a/vendor/hayro-syntax/src/filter/ascii_85.rs +++ b/vendor/hayro-syntax/src/filter/ascii_85.rs @@ -4,7 +4,9 @@ use crate::reader::Reader; use crate::trivia::is_white_space_character; use alloc::vec::Vec; -pub(crate) fn decode(data: &[u8]) -> Option> { +/// PdfCraft patch: `limit` is the most bytes produced (see `filter::decode_limit`); each `z` costs +/// one input byte and yields four, so a Flate stream of them would outgrow the budget. +pub(crate) fn decode(data: &[u8], limit: usize) -> Option> { const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; let mut reader = Reader::new(data); @@ -55,13 +57,21 @@ pub(crate) fn decode(data: &[u8]) -> Option> { Some(()) }; - let mut decoded = Vec::with_capacity(data.len() * 4 / 5); + let mut decoded = Vec::with_capacity((data.len() * 4 / 5).min(limit)); let mut group = Vec::with_capacity(5); loop { + if decoded.len() >= limit { + decoded.truncate(limit); + warn!("ascii85 stream stopped at its decode limit of {limit} bytes"); + + return Some(decoded); + } + let Some(b) = read_byte() else { // Be lenient and accept what we have (see PDFBOX-5910). flush_group(&mut group, &mut decoded)?; + decoded.truncate(limit); return Some(decoded); }; @@ -82,6 +92,7 @@ pub(crate) fn decode(data: &[u8]) -> Option> { // Technically requires a '>', but there is a PDF where it isn't // appended and decodes fine in other viewers. flush_group(&mut group, &mut decoded)?; + decoded.truncate(limit); return Some(decoded); } @@ -89,3 +100,16 @@ pub(crate) fn decode(data: &[u8]) -> Option> { } } } + +#[cfg(test)] +mod tests { + use super::decode; + + #[test] + fn output_stops_at_the_limit() { + assert_eq!(decode(&[b'z'; 100], 10).unwrap().len(), 10); + assert_eq!(decode(b"zz~>", 100).unwrap(), [0; 8]); + assert_eq!(decode(b"!!!!~>", 2).unwrap().len(), 2); + assert_eq!(decode(b"!!!!", 2).unwrap().len(), 2); + } +} diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 98924f2d..b83e2c41 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -15,11 +15,25 @@ pub(crate) fn decode( ) -> Option> { let k = params.get::(K).unwrap_or(0); - let rows = params.get::(ROWS).unwrap_or(image_params.height); + // PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and + // the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank. + // PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a + // plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black + // (#3308). The decoder still stops where the data ends. + let rows = params + .get::(ROWS) + .filter(|&rows| rows != 0) + .map_or(image_params.height, |rows| rows.max(image_params.height)); let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); + // A `/Columns` past `u32::MAX` is refused: truncated, it would wrap to a small width that + // passes `ccitt_size_ok` and decode the image at the wrong width. + let columns = match params.get::(COLUMNS) { + Some(columns) => u32::try_from(columns).ok()?, + None => 1728, + }; let settings = DecodeSettings { - columns: params.get::(COLUMNS).unwrap_or(1728) as u32, + columns, rows, end_of_block, end_of_line: params.get::(END_OF_LINE).unwrap_or(false), @@ -154,3 +168,22 @@ pub(crate) fn decode( }), }) } + +#[cfg(test)] +mod tests { + use super::decode; + use crate::object::stream::ImageDecodeParams; + use crate::object::{Dict, FromBytes}; + + // An 8 x 4 all-black Group 4 stream: one horizontal-mode row, three V0 rows, then EOFB. + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + + #[test] + fn columns_past_u32_max_are_refused_not_wrapped() { + let params = ImageDecodeParams { width: 8, height: 4, ..Default::default() }; + let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms); + assert!(decoded(b"<< /K -1 /Columns 8 >>").is_some()); + // 2^32 + 8 would wrap to 8 columns. + assert!(decoded(b"<< /K -1 /Columns 4294967304 >>").is_none()); + } +} diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 90ffd76e..e3622795 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -95,7 +95,7 @@ impl Filter { Self::AsciiHexDecode => ascii_hex::decode(data) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::Ascii85Decode => ascii_85::decode(data) + Self::Ascii85Decode => ascii_85::decode(data, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), Self::RunLengthDecode => run_length::decode(data, limit) diff --git a/vendor/hayro-syntax/src/object/number.rs b/vendor/hayro-syntax/src/object/number.rs index b9985612..b887333e 100644 --- a/vendor/hayro-syntax/src/object/number.rs +++ b/vendor/hayro-syntax/src/object/number.rs @@ -70,6 +70,13 @@ impl Number { impl Skippable for Number { fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> { let has_sign = r.forward_if(|b| b == b'+' || b == b'-').is_some(); + // PdfCraft patch: one more sign, so that skipping a number covers what `read_inner` + // reads. `--280` was already covered by the `b'-'` arm below, but `-+280` and `++280` + // were not, and a skip that fails where a read succeeds puts the reader back where it + // started (#3630 review). + if has_sign { + r.forward_if(|b| b == b'+' || b == b'-'); + } // Some PDFs have weird trailing minuses, so try to accept those as well. match r.peek_byte()? { @@ -113,19 +120,25 @@ impl Readable<'_> for Number { #[inline(always)] fn read_inner(r: &mut Reader<'_>) -> Option { - let first = r.peek_byte()?; - let has_sign = matches!(first, b'+' | b'-'); - let negative = match first { - b'-' => { - r.forward(); - true - } - b'+' => { - r.forward(); - false - } - _ => false, - }; + // PdfCraft patch: a doubled sign is one number, not two. `--280` left `negative` set with no + // digits read, so it returned 0 and the reader stopped on `-280`, which became a second + // number: a `cm` with it got seven operands, kept the last six, and the matrix + // `0 0 1 0 -280 10` hid everything drawn under it (#3565). `Number::skip` above already + // consumes the whole token, so read and skip disagreed about where the number ended. + // + // One extra sign is absorbed and the value is negative if either sign was a minus, which is + // what the COS lexer does since #3299 (`--280` and `+-280` are both -280). A third sign is + // left to the digit loop's trailing-minus arm, as before. + let mut negative = false; + let mut signs = 0; + while signs < 2 + && let Some(b @ (b'-' | b'+')) = r.peek_byte() + { + r.forward(); + negative |= b == b'-'; + signs += 1; + } + let has_sign = signs > 0; // PdfCraft patch: high-precision float mantissa overflow (jsPDF double-precision coordinates). // Digits that don't fit in `mantissa` are dropped: past the dot they are only precision; @@ -350,6 +363,39 @@ mod tests { use crate::reader::Reader; use crate::reader::ReaderExt; + /// PdfCraft patch: a doubled sign is one number. `--280` returned 0 and left `-280` to be + /// read as a second number, so a `cm` carrying it got seven operands and a degenerate matrix + /// (#3565). The COS lexer reads these the same way since #3299. + #[test] + fn a_doubled_sign_is_one_number() { + for (src, want) in [("--280", -280), ("+-280", -280), ("-+280", -280), ("++280", 280), ("-280", -280), ("+280", 280)] { + let mut r = Reader::new(src.as_bytes()); + assert_eq!(r.read_without_context::().unwrap(), want, "{src}"); + assert!(r.at_end(), "{src}: the whole number was read, leaving nothing for a second one"); + } + // The fraction too, and a lone sign is still zero. + assert_eq!(Reader::new("--280.5".as_bytes()).read_without_context::().unwrap(), -280.5); + assert_eq!(Reader::new("-".as_bytes()).read_without_context::().unwrap(), 0); + + // Skipping covers what reading accepts: a skip that fails where a read succeeds puts the + // reader back where it started, so `Object::skip` would leave the number in place + // (#3630 review). + for src in ["--280", "+-280", "-+280", "++280", "-280", "+280", "--280.5"] { + let mut r = Reader::new(src.as_bytes()); + assert!(r.skip::(false).is_some(), "{src}: not skipped"); + assert!(r.at_end(), "{src}: only part of the number was skipped"); + } + // A doubled sign alone is a zero token, like a single one. + for token in ["--", "+-", "-+", "++"] { + let bytes = format!("{token} 3"); + let mut skipped = Reader::new(bytes.as_bytes()); + assert!(skipped.skip::(false).is_some(), "{token}"); + let mut read = Reader::new(bytes.as_bytes()); + assert_eq!(read.read_without_context::().unwrap().as_f64(), 0.0, "{token}"); + assert_eq!(read.offset(), skipped.offset(), "{token}"); + } + } + #[test] fn tolerant_signed_and_dot_zero_tokens_match_skip() { for token in ["+", "-", ".", "+.", "-.", "0", "+0", "-0", "0.", "+0.", "-0."] { diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index c975ed9f..7de3b297 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -351,14 +351,25 @@ impl FilterResult<'_> { } } +/// PdfCraft patch (20): the end-of-line after the `stream` keyword. Microsoft Reporting Services +/// writes `stream \r\n`, a space before the line break, which the specification does not allow. +/// Without this the content stream was taken to start at the space, failed to inflate, and the +/// page rendered blank (bank statements, invoices) while other viewers showed it. Only spaces and +/// tabs are skipped, and only when a line break follows, so stream data is never swallowed. +fn forward_stream_eol(r: &mut Reader<'_>) -> Option<()> { + r.forward_while(|b| b == b' ' || b == b'\t'); + r.forward_tag(b"\n") + .or_else(|| r.forward_tag(b"\r\n")) + // Technically not allowed, but no reason to not try it. + .or_else(|| r.forward_tag(b"\r")) +} + fn parse_proper<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { let length = dict.get::(LENGTH)?; r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let data = r.read_bytes(length as usize)?; r.skip_white_spaces(); r.forward_tag(b"endstream")?; @@ -374,11 +385,7 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> // A wrong `/Length` still recovers, because that keyword sits directly after the dict. r.skip_white_spaces_and_comments(); r.forward_tag(b"stream")?; - - r.forward_tag(b"\n") - .or_else(|| r.forward_tag(b"\r\n")) - // Technically not allowed, but no reason to not try it. - .or_else(|| r.forward_tag(b"\r"))?; + forward_stream_eol(r)?; let tail = r.tail()?; let endstream_offset = find_needle(tail, b"endstream")?; @@ -463,4 +470,21 @@ mod tests { let mut r = Reader::new(data); assert!(r.read_with_context::>(&ReaderContext::dummy()).is_none()); } + + /// PdfCraft patch (20): spaces or tabs between `stream` and its line break are skipped. + #[test] + fn stream_keyword_may_be_followed_by_spaces_before_the_line_break() { + for data in [ + &b"<< /Length 10 >> stream \r\nabcdefghij\nendstream"[..], + &b"<< /Length 10 >> stream\t \nabcdefghij\nendstream"[..], + &b"<< /Length 999 >> stream \r\nabcdefghij\nendstream"[..], + ] { + let mut r = Reader::new(data); + let stream = r + .read_with_context::>(&ReaderContext::dummy()) + .unwrap(); + + assert_eq!(stream.data, b"abcdefghij"); + } + } } diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 20d6adef..62e8c457 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -604,9 +604,17 @@ impl<'a> Page<'a> { { (A4.width() as f32, A4.height() as f32) } else { + // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. + // The stored unit is finite and within 1..=75000. Round upward only if the + // reciprocal's f32 product would fall below one and trigger a placeholder. + let mut raw_floor = 1.0 / self.user_unit; + if raw_floor * self.user_unit < 1.0 { + raw_floor = raw_floor.next_up(); + } + let raw_floor = f64::from(raw_floor); ( - crop_box.width().max(1.0) as f32, - crop_box.height().max(1.0) as f32, + crop_box.width().max(raw_floor) as f32, + crop_box.height().max(raw_floor) as f32, ) } } @@ -1005,6 +1013,45 @@ mod page_tree_tests { assert_eq!(repaired.get().len(), 1); assert_eq!(repaired.get()[0].raw().obj_id().unwrap().obj_number, 3); } + + fn raw_crop_unit_page(unit: f64, crop: [f64; 4]) -> Vec { + let bounds = crop.map(|n| n.to_string()).join(" "); + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Resources << >> /Contents [] >>"), + ]; + pdf_bytes(&objects) + } + + /// PdfCraft patch: a tiny crop box keeps a one-point floor after `/UserUnit`, not one raw unit. + #[test] + fn raw_crop_unit_floor_is_one_point_after_user_unit() { + for (unit, width, height) in [(1.0, 1.0, 1.0), (4096.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0)] { + let pdf = Pdf::new(raw_crop_unit_page(unit, [10.0, 20.0, 10.125, 20.25])).unwrap(); + let page = &pdf.pages()[0]; + assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32), "unit={unit}"); + } + // In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994. + for unit in [1.0, 41.0, 47.0] { + let pdf = Pdf::new(raw_crop_unit_page(unit, [0.0, 0.0, 0.01, 0.01])).unwrap(); + let page = &pdf.pages()[0]; + let (width, height) = page.base_dimensions(); + assert!(width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0, "unit={unit}"); + let (width, height) = page.render_dimensions(); + assert!((1.0..=1.000001).contains(&width) && (1.0..=1.000001).contains(&height), "unit={unit}: {width} by {height}"); + } + // A nearly-zero crop box still falls back to A4. + for unit in [1.0, 4096.0, 75000.0] { + for side in [0.0, 0.0001] { + let pdf = Pdf::new(raw_crop_unit_page(unit, [0.0, 0.0, side, side])).unwrap(); + let page = &pdf.pages()[0]; + let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32); + assert_eq!(page.base_dimensions(), expected, "unit={unit}, side={side}"); + assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32)); + } + } + } } #[cfg(test)] diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index a86a1f4a..15858ac4 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -984,7 +984,9 @@ fn xref_stream_subsection<'a>( bytes.iter().fold(0u64, |acc, b| (acc << 8) | *b as u64) }; - let obj_number = start + i; + // PdfCraft patch: object numbers are stored as `i32`; a subsection running past + // `i32::MAX` is refused instead of overflowing or turning negative. + let obj_number = i32::try_from(start.checked_add(i)?).ok()?; match f_type { // We don't care about free objects. @@ -1007,7 +1009,7 @@ fn xref_stream_subsection<'a>( }; insert_map.insert( - ObjectIdentifier::new(obj_number as i32, gen_number as i32), + ObjectIdentifier::new(obj_number, gen_number as i32), EntryType::Normal(offset as usize), ); } @@ -1025,7 +1027,7 @@ fn xref_stream_subsection<'a>( }; insert_map.insert( - ObjectIdentifier::new(obj_number as i32, gen_number), + ObjectIdentifier::new(obj_number, gen_number), EntryType::ObjStream(obj_stream_number, index), ); } @@ -1177,6 +1179,13 @@ mod tests { let mut whole = Reader::new(&data[..6]); assert!(xref_stream_subsection(&mut whole, 0, 2, 1, 1, 1, &mut map).is_some()); assert_eq!(map.len(), 2); + // A subsection running past object number i32::MAX is refused. + for start in [i32::MAX as u32, u32::MAX] { + let mut past_end = XrefMap::default(); + let mut wrap = Reader::new(&data[..6]); + assert!(xref_stream_subsection(&mut wrap, start, 2, 1, 1, 1, &mut past_end).is_none()); + assert!(past_end.keys().all(|id| id.obj_number >= 0)); + } } #[test]