diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d5b820d24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, +# wasm) on every pull request and every push to main. Today only path-filtered workflows run on +# pull requests, so most PRs land with no test run at all. +# +# Ported from wordcraft#800. Linux only; standard GitHub-hosted runner, no secrets. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Same craft-fonts commit as release.yml, so the font tests see what a release build embeds. + CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695 + +jobs: + gate: + name: cargo xtask ci + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + RUST_BACKTRACE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + persist-credentials: false + + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + + - name: Install Rust (stable + clippy, rustfmt, wasm32) + run: | + rustup toolchain install stable --profile minimal --component clippy,rustfmt \ + --target wasm32-unknown-unknown + rustup default stable + + - uses: taiki-e/install-action@f7e5d7c961414b23f5b25b2da9294395d08513ad # v2.87.26 + with: + tool: cargo-nextest@0.9.145 + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Quality gate + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + run: cargo xtask ci diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 65693656c..0be7fa87b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI crate is swappable. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: type tool caret navigation`). diff --git a/ASSETS.md b/ASSETS.md index 2731f47bf..407a26c92 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -40,12 +40,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/page-4.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/page-cover.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | | `docs/images/ui-spread.png` | DesignCraft contributors | screenshot/render of DesignCraft itself (sample document generated in code) | MIT OR Apache-2.0 | Original; no Adobe UI | -| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | -| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-spread.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow-text.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/drop-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/feather.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/inner-shadow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | +| `crates/render/tests/golden/outer-glow.png` | DesignCraft contributors (generated) | rendered by DesignCraft's own renderer (`crates/render/tests/it/effects.rs`, synthetic shapes and bundled fonts) | MIT OR Apache-2.0 | Golden test image for soft effects | | `assets/app-icon/LICENSE.txt` | (licence text) | this project | — | Licence for the app icon | | `assets/app-icon/designcraft-1024.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | | `assets/app-icon/designcraft-macos-512.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | @@ -69,3 +69,28 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | + +## Assets bundled by the PDF backend + +These assets were already enabled by Hayro’s default font and CMap features. The pinned +backend keeps the same files; hashes and upstream licence notices are retained here. + +| Crate / file | Author | Source | Licence | SHA-256 | Use | +| --- | --- | --- | --- | --- | --- | +| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `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 data, allowed by the PDF compatibility exception | +| `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 data, allowed by the PDF compatibility exception | diff --git a/Cargo.lock b/Cargo.lock index 61b84b72d..da8c59928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,6 +686,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -957,7 +966,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.9.0" +version = "0.12.0" dependencies = [ "arboard", "designcraft-engine", @@ -979,7 +988,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -995,7 +1004,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.9.0" +version = "0.12.0" dependencies = [ "craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)", "kurbo", @@ -1007,7 +1016,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1027,8 +1036,9 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-geom", "proptest", @@ -1039,7 +1049,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.9.0" +version = "0.12.0" dependencies = [ "caseless", "craft-atomic", @@ -1078,8 +1088,9 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-doc", "zip", @@ -1087,7 +1098,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.9.0" +version = "0.12.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1100,7 +1111,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1112,7 +1123,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.9.0" +version = "0.12.0" dependencies = [ "flo_curves", "kurbo", @@ -1125,7 +1136,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1140,7 +1151,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.9.0" +version = "0.12.0" dependencies = [ "craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)", "designcraft-color", @@ -1155,7 +1166,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1168,8 +1179,9 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "chrono", "craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)", "craft-pdf", "craft-prepress", @@ -1192,8 +1204,9 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "chrono", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1209,25 +1222,26 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.9.0" +version = "0.12.0" dependencies = [ + "craft-pdf", "designcraft-color", "designcraft-compose", "designcraft-doc", "designcraft-fonts", "designcraft-geom", "designcraft-images", - "hayro", "image", "log", "serde", + "thiserror 2.0.21", "vello_common 0.2.0", "vello_cpu 0.2.0", ] [[package]] name = "designcraft-textimport" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-doc", "quick-xml", @@ -1236,7 +1250,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1248,7 +1262,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.9.0" +version = "0.12.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1275,7 +1289,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.9.0" +version = "0.12.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5665,7 +5679,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" +source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", @@ -5839,7 +5853,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.9.0" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index c50b2dc1b..3c8b1e6ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,14 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] -# Shared writer; the immutable revision must be published before this consumer. +# Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.1.0", default-features = false } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. craft-prepress = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.1.0", features = ["flatten"] } @@ -41,6 +41,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" rayon = "1" unicode-bidi = "0.3" @@ -68,7 +70,6 @@ krilla-svg = "=0.8.1" flo_curves = "0.8" # QR codes (Object › Generate QR Code); MIT. qrcodegen = "1.8" -hayro = "0.7" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # INDD/INDT → IDML conversion for File › Open: storytold/indd-utils, tracking its main branch @@ -104,11 +105,16 @@ todo = "warn" too_many_arguments = "allow" type_complexity = "allow" +# Line tables keep file and line in panics and backtraces; full debug info +# for every test binary and dependency would multiply the target dir size. +# The test profile inherits both settings. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 @@ -118,11 +124,11 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } krilla = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.8.2" } +winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } # The hayro crates from craft-libs' vendor/ directory (pdfcraft's patches plus craft-libs'; see its # vendor/README.craft-reader.md), at the craft-pdf revision. -hayro = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" } -hayro-interpret = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" } -hayro-syntax = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" } -hayro-jbig2 = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4" } +hayro = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/aiongg/craft-libs", rev = "d30598dcb4b389561c485b97d4a97a0c86bc36e4", version = "=0.3.0" } diff --git a/NOTICE b/NOTICE index f4e3184f6..f1e997156 100644 --- a/NOTICE +++ b/NOTICE @@ -81,12 +81,35 @@ list (author, source, license) is in ASSETS.md. packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. - hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 - (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at - the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + (Apache-2.0 OR MIT) are fetched from the pinned craft-libs source documented below. hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively -licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. +licensed (MIT/Apache-2.0/BSD/ISC/Zlib), fetched through crates.io or the pinned source below. No Adobe icons, images or artwork are included. + +PDF backend source + +The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 +are fetched from https://github.com/storytold/craft-libs at revision +89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred +from https://github.com/storytold/pdfcraft at revision +7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is +https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each +crate's upstream licence files. The patch ledger is at +https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md. +Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset +origins and unchanged per-file hashes are recorded separately in ASSETS.md. + +Assets already bundled through Hayro's default features retain their licences: +- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. + Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see + licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT. +- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see + licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt. +- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause, + non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt. + Adobe and contributor names do not endorse or promote this product. +Per-file hashes and sources are recorded in ASSETS.md. diff --git a/README.md b/README.md index 880433e33..cd0f56479 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ DesignCraft icon: an engraved cat in a polka-dot scarf on a green tile +

+

DesignCraft

@@ -25,6 +29,9 @@ Runs on macOS, Windows, Linux and the web License: MIT OR Apache-2.0 Agent-drivable over MCP +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

diff --git a/ROADMAP.md b/ROADMAP.md index 82da9fd22..5dbc9326a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. @@ -351,6 +351,18 @@ Newest first. - **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change. - **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change. - **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-color stops export as before. No readiness percentage change. +- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. +- **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. +- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. +- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. +- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. +- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. @@ -358,6 +370,14 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. +- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. @@ -641,3 +661,5 @@ Newest first. The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published. - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. + +- PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/it/cli.rs similarity index 100% rename from apps/designcraft-cli/tests/cli.rs rename to apps/designcraft-cli/tests/it/cli.rs diff --git a/apps/designcraft-cli/tests/it/main.rs b/apps/designcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..26710c101 --- /dev/null +++ b/apps/designcraft-cli/tests/it/main.rs @@ -0,0 +1 @@ +mod cli; diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe0f4a7fa..27d236a2f 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if request.purpose == "shortcuts" { + rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"]) } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs index d3e0dbea7..04238e853 100644 --- a/apps/designcraft/src/logging.rs +++ b/apps/designcraft/src/logging.rs @@ -90,27 +90,16 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) -} - -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -317,6 +306,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b56878176..13416e4e0 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { match purpose { "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], + "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 86a88e5c0..1ac9437d1 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -27,6 +27,9 @@ pub struct NativeMenu { shown_for: (String, bool), /// File › Open Recent, rebuilt when the list of recent files changes. recent: Option, + /// The commands with key equivalents and the shortcut each was given: the menu is rebuilt + /// when one changes, so a shortcut moved to another command leaves its old item. + keys: Vec<(String, Option)>, } struct RecentMenu { @@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String { menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label)) } -/// A command's default shortcut, as the in-window menus show it. -fn shortcut(id: &str) -> Option<&'static str> { - menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut)) +/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it. +fn shortcut(app: &DesignApp, id: &str) -> Option { + menus::shortcut_of(app, id) } impl NativeMenu { pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self { // A click in the menu bar reaches the app with no input over the window, so nothing would // draw the frame that runs the command until the pointer or a key comes back: wake egui. + // muda keeps the first handler for the life of the process, so this channel serves every + // menu bar built later (`build_bar`). let (tx, events) = channel(); let repaint = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { let _ = tx.send(e); repaint.request_repaint(); })); + Self::build_bar(app, events) + } + + /// The menu bar for the current language and settings, fed by `events`. + fn build_bar(app: &mut DesignApp, events: Receiver) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); let mut counter = 0usize; @@ -75,12 +85,24 @@ impl NativeMenu { for entry in menus::MAC_APP_MENU { let _ = match *entry { AppMenuItem::Cmd { id, label, command } => { - let sc = shortcut(command).and_then(accel); + let sc = shortcut(app, command).as_deref().and_then(accel); let item = MenuItem::with_id(id, menu_title(app, label), true, sc); let appended = app_menu.append(&item); items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item))); appended } + AppMenuItem::Language => { + let sub = Submenu::new(menu_title(app, "Language"), true); + for (code, name) in designcraft_ui_egui::i18n::LANGUAGES { + let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code }); + let params = serde_json::json!({ "lang": code }); + let checked = menus::checked(app, "app.language", ¶ms).unwrap_or(false); + let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None); + let _ = sub.append(&item); + items.insert(id, ("app.language".into(), params, Handle::Check(item))); + } + app_menu.append(&sub) + } AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()), AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)), AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)), @@ -96,15 +118,19 @@ impl NativeMenu { } menu.init_for_nsapp(); app.native_menu = true; - app.native_shortcuts = items - .values() - .filter_map(|(id, p, _)| { - let sc = shortcut(id)?; - (p.is_null() && accel(sc).is_some()).then(|| id.clone()) - }) - .collect(); + let keys: Vec<(String, Option)> = + items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect(); + app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect(); let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None }); - Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent } + Self { + _menu: menu, + items, + events, + last_refresh: 0.0, + shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), + recent, + keys, + } } /// Put the current recent files at the top of File › Open Recent when they changed. @@ -153,13 +179,21 @@ impl NativeMenu { // back by Cancel): the system menu bar follows at once, like the panels (the new menu // replaces the old one as the application's menu before that one is dropped). if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 { - *self = Self::install(app, ctx); + let events = std::mem::replace(&mut self.events, channel().1); + *self = Self::build_bar(app, events); } let now = designcraft_ui_egui::now_ms(); if now - self.last_refresh < 250.0 { return; } self.last_refresh = now; + // A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the + // menu bar before the window sees the key, so the bar must carry the new keys. + if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) { + let events = std::mem::replace(&mut self.events, channel().1); + *self = Self::build_bar(app, events); + return; + } self.refresh_recent(app); for (cmd, params, h) in self.items.values() { let en = menus::native_menu_enabled(app, ctx, cmd); @@ -199,10 +233,10 @@ fn build( } // Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes. Item::Cmd { id, .. } if !menus::shown(app, id) => {} - Item::Cmd { label, id, params, shortcut } => { + Item::Cmd { label, id, params, .. } => { *counter += 1; let mid = format!("dc{counter}"); - let sc = if params.is_null() { shortcut.and_then(accel) } else { None }; + let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None }; let label = menu_title(app, label); let handle = if menus::checked(app, id, params).is_some() { let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..89ea89bd3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1860,11 +1860,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None }; } // Rules and shading for the paragraph. - if let Some((fi, bl, asc, _)) = first_line_rect { + if let Some((fi, bl, _, _)) = first_line_rect { let ft = &mut out.frames[fi]; if pp.rule_above.on { let r = &pp.rule_above; - let y = bl - asc - r.offset; + // The offset runs from the first line's baseline up to the rule's bottom edge. + let y = bl - r.offset; let col = ft .lines .iter() diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..15c412ada 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7290,3 +7290,23 @@ fn greek_text_gets_greek_hyphenation() { let el = hyphen::Lang::for_language("Greek").unwrap(); assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]); } + +/// Rule Above's offset runs from the first line's baseline up to the rule's bottom edge, as in +/// InDesign: a rule set clear of the cap height sits between the rows, not across the row above +/// (an INDD project list whose rules crossed the previous paragraph's text). +#[test] +fn rule_above_offset_is_measured_from_the_baseline() { + use designcraft_doc::Rule; + let rule = Rule { on: true, weight: 1.5, offset: 10.0, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(rule), ..Default::default() }; + let (d, sid, fid) = doc_with("Row one\nRow two", Rect::new(0.0, 0.0, 300.0, 200.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (first, second) = (lines[0], lines[1]); + let mut decos: Vec = cs.frame(fid).unwrap().decos.iter().map(|dc| dc.rect).collect(); + decos.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + let rule = decos[1]; + assert!((rule.y1 - (second.baseline - 10.0)).abs() < 1e-6, "{rule:?}, baseline {}", second.baseline); + assert!((rule.height() - 1.5).abs() < 1e-6); + assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline); +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index ed7430db7..1d3da4062 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -14,6 +14,7 @@ designcraft-color = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index f8ae0ec19..d4500f7ce 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -343,6 +343,8 @@ impl Document { let mut pages: Vec<(Page, Vec>)> = Vec::new(); let mut parity: Vec = Vec::new(); let mut kept: Vec<(usize, Arc)> = Vec::new(); + // Whether each shuffled page was a left page, to mirror its dragged columns if it moves. + let mut was_left: BTreeMap = BTreeMap::new(); let mut abs = 0; for sp in self.spreads.drain(..) { if !sp.allow_shuffle && sp.pages.len() > 1 { @@ -350,8 +352,9 @@ impl Document { kept.push((pages.len(), sp)); continue; } - for _ in &sp.pages { + for p in &sp.pages { parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0)); + was_left.insert(p.id, p.side == PageSide::Left); abs += 1; } let mut sp = Arc::unwrap_or_clone(sp); @@ -445,6 +448,19 @@ impl Document { out.insert(at, sp); } self.spreads = out; + // A page that crosses the spine keeps its inside and outside margins, so its margin box + // mirrors; its dragged column guides mirror with it, as in InDesign. + let crossed = |p: &Page| was_left.get(&p.id).is_some_and(|&l| l != (p.side == PageSide::Left)) && p.columns.positions.is_some(); + for sp in &mut self.spreads { + if !sp.pages.iter().any(crossed) { + continue; + } + for p in &mut Arc::make_mut(sp).pages { + if crossed(&*p) { + p.columns = p.columns.mirrored(p.margin_rect().width()); + } + } + } // Pages can change sides, and so parent pages. self.sync_parent_margins(); } @@ -466,6 +482,10 @@ impl Document { if let Some(t) = &template { p.width = t.width; p.height = t.height; + // Dragged column guides too: the same width and margins keep them valid. + // They are taken as for this single page; repaginating mirrors them if the + // page lands on a left page. + p.columns.positions = p.columns_from(t).positions; } ids.push(p.id); p @@ -675,7 +695,7 @@ impl Document { if page.local_margins { continue; } - let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else { + let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, page.columns_from(q))) else { continue; }; if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) { @@ -879,6 +899,65 @@ mod tests { assert!(d.delete_pages(&[0, 1]).is_err()); } + /// Pages inserted after a page with dragged column guides keep them, as they keep its column + /// count and gutter; pages that follow a parent take the parent's. + #[test] + fn inserted_pages_keep_dragged_columns() { + let dragged = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + let widths = |p: &Page| p.column_rects().iter().map(|r| r.width()).collect::>(); + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); + let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0]; + (pg.columns, pg.local_margins) = (dragged.clone(), true); + d.insert_pages(Some(0), 2, None).unwrap(); + for i in 1..3 { + let p = d.page(i).unwrap(); + assert_eq!(p.columns, dragged, "page {i}"); + assert_eq!(widths(p), vec![100.0, 410.0], "page {i}"); + } + + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); + Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged.clone(); + d.sync_parent_margins(); + d.insert_pages(Some(0), 1, d.parents.first().map(|p| p.id)).unwrap(); + assert_eq!(d.page(1).map(widths), Some(vec![100.0, 410.0])); + } + + /// A right page whose gutter was dragged toward the spine keeps it there when inserting a + /// page before it makes it a left page: its dragged columns mirror across the margin box. + #[test] + fn dragged_columns_mirror_when_a_page_changes_side() { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: true, ..Default::default() }); + let pg = &mut Arc::make_mut(&mut d.spreads[0]).pages[0]; + assert_eq!(pg.side, PageSide::Right); + let w = pg.margin_rect().width(); + (pg.columns, pg.local_margins) = (Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 112.0, w]) }, true); + let id = pg.id; + // How far the gutter's spine-side edge is from the margin on the spine side. + let from_spine = |p: &Page| { + let (m, c) = (p.margin_rect(), p.column_rects()); + match p.side { + PageSide::Left => m.x1 - c[1].x0, + _ => c[0].x1 - m.x0, + } + }; + assert!((from_spine(d.page(0).unwrap()) - 100.0).abs() < 1e-9); + + d.insert_pages(None, 1, None).unwrap(); + let moved = d.page(1).unwrap(); + assert_eq!((moved.id, moved.side), (id, PageSide::Left)); + assert!((from_spine(moved) - 100.0).abs() < 1e-9, "{:?}", moved.column_rects()); + let widths: Vec = moved.column_rects().iter().map(|r| r.width()).collect(); + assert!((widths[0] - (w - 112.0)).abs() < 1e-9 && (widths[1] - 100.0).abs() < 1e-9, "{widths:?}"); + // The inserted page is a right page like the one it copies: no mirroring. + assert_eq!(d.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 112.0, w])); + + // Moving it back to the right undoes the mirror. + d.delete_pages(&[0]).unwrap(); + let back = d.page(0).unwrap(); + assert_eq!(back.side, PageSide::Right); + assert!((from_spine(back) - 100.0).abs() < 1e-9, "{:?}", back.column_rects()); + } + #[test] fn items_travel_with_pages() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..b2d3b4f4c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -35,6 +35,7 @@ pub mod story; pub mod styles; mod swatch_refs; pub mod table; +pub mod time; pub mod vars; pub mod xref; @@ -398,11 +399,19 @@ pub struct DocSettings { /// Lock Zero Point: dragging from the ruler corner leaves it where it is. #[serde(skip_serializing_if = "std::ops::Not::not")] pub zero_point_locked: bool, + /// View › Grids & Guides › Lock Column Guides (IDML `ColumnGuideLocked`): column guides can't + /// be dragged. On in new documents, as in InDesign. + #[serde(skip_serializing_if = "is_true")] + pub column_guides_locked: bool, /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets /// with nothing selected (InDesign's document defaults). pub new_object: NewObjectColors, } +fn is_true(b: &bool) -> bool { + *b +} + fn is_origin(p: &[f64; 2]) -> bool { p[0] == 0.0 && p[1] == 0.0 } @@ -460,6 +469,7 @@ impl Default for DocSettings { ruler_origin: RulerOrigin::Spread, zero_point: [0.0, 0.0], zero_point_locked: false, + column_guides_locked: true, new_object: NewObjectColors::default(), } } diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 862a1cda9..7041f7ed8 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -42,6 +42,86 @@ impl Default for Columns { } } +/// The most columns a page can have, as in InDesign. +pub const MAX_COLUMNS: u32 = 216; + +/// How far, in points, the first and last custom column positions may sit from the margin box's +/// edges: room for float noise in files and page arithmetic, far less than any dragged guide. +pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01; + +/// Whether `positions` are usable custom column positions for `count` columns across a margin box +/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an +/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the +/// whole list, the first at 0 and the last at `margin_width` (within +/// [`COLUMN_POSITION_TOLERANCE`]). +pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool { + (1..=MAX_COLUMNS).contains(&count) + && positions.len() == 2 * count as usize + && margin_width.is_finite() + && positions.iter().all(|v| v.is_finite()) + && positions.windows(2).all(|w| matches!(w, [a, b] if a <= b)) + && positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE) + && positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE) +} + +impl Columns { + /// The custom positions when they are valid for a margin box `margin_width` wide (see + /// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set + /// that no longer fits (a page resized after the drag, a hostile file) is not drawn. + pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> { + let positions = self.positions.as_deref()?; + valid_column_positions(positions, self.count, margin_width).then_some(positions) + } + + /// Every column's left and right edge as offsets from the margin box's left edge: the valid + /// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`. + pub fn edges(&self, margin_width: f64) -> Vec { + let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 }; + match self.valid_positions(w) { + Some(p) => p.iter().map(|&v| inside(v, w)).collect(), + None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(), + } + } + + /// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first + /// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left + /// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its + /// neighbouring gutters or the margins: no column gets a negative width. `None` when there is + /// no such gutter, `new_left` is not finite, or the result would not be valid. + pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option> { + if !new_left.is_finite() { + return None; + } + let mut pos = self.edges(margin_width); + let i = gutter.checked_mul(2)?.checked_add(1)?; + let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else { + return None; + }; + let width = right - left; + let at = new_left.min(hi - width).max(lo); + let [l, r] = pos.get_mut(i..i + 2)? else { + return None; + }; + *l = at; + *r = (at + width).min(hi); + valid_column_positions(&pos, self.count, margin_width).then_some(pos) + } + + /// These columns reflected across a margin box `margin_width` wide, as on the facing page + /// across the spine: each custom position `p` becomes `margin_width - p`, in reverse order so + /// they stay ascending. Count and gutter stay; positions that are not valid (see + /// [`Columns::valid_positions`]) become `None`, the even split, which mirrors onto itself. + pub fn mirrored(&self, margin_width: f64) -> Columns { + let positions = self.valid_positions(margin_width).map(|p| p.iter().rev().map(|&v| inside(margin_width - v, margin_width)).collect()); + Columns { count: self.count, gutter: self.gutter, positions } + } +} + +/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN. +fn inside(v: f64, w: f64) -> f64 { + v.max(0.0).min(w) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Orientation { @@ -233,9 +313,10 @@ impl Page { pub fn bounds(&self) -> Rect { Rect::new(self.x, 0.0, self.x + self.width, self.height) } - /// Same margins and columns as `other`. + /// Same margins and columns as `other`, with `other`'s columns as it would take them from this + /// page: mirrored when one is a left page and the other isn't. pub fn same_margins(&self, other: &Page) -> bool { - self.margins == other.margins && self.columns == other.columns + self.margins == other.margins && other.columns_from(self) == other.columns } /// Left and right margins resolved for the page side (inside is at the spine). pub fn left_right_margins(&self) -> (f64, f64) { @@ -244,23 +325,39 @@ impl Page { _ => (self.margins.inside, self.margins.outside), } } + /// `src`'s columns as they fall on this page: the same when both pages put their inside + /// margin on the same physical side, mirrored across `src`'s margin box when one is a left + /// page and the other is not, as InDesign mirrors dragged column guides across the spine. + pub fn columns_from(&self, src: &Page) -> Columns { + if (self.side == PageSide::Left) == (src.side == PageSide::Left) { + src.columns.clone() + } else { + src.columns.mirrored(src.margin_rect().width()) + } + } /// The live area inside the margins. pub fn margin_rect(&self) -> Rect { let (l, r) = self.left_right_margins(); let b = self.bounds(); Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top)) } - /// Column rects within the margins. + /// Column rects within the margins: the custom positions when the column guides have been + /// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even + /// split. pub fn column_rects(&self) -> Vec { let m = self.margin_rect(); - column_rects(m, self.columns.count.max(1), self.columns.gutter) + let w = m.width(); + match self.columns.valid_positions(w) { + Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(), + None => column_rects(m, self.columns.count.max(1), self.columns.gutter), + } } } /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { // The public allocator also receives native/imported options, outside command validation. - let n = n.clamp(1, 216) as usize; + let n = n.clamp(1, MAX_COLUMNS) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -450,6 +547,118 @@ mod tests { assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9); } + /// Dragged column guides place the columns; a set that cannot mean anything falls back to the + /// even split rather than producing nothing or a column outside the margins (#1096). + #[test] + fn custom_column_positions_place_the_columns() { + let mut p = page(PageSide::Single, 0.0); + let m = p.margin_rect(); + let w = m.width(); + let even = p.column_rects(); + + p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]); + assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]); + // Float noise at the edges is absorbed and kept inside the box. + p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]); + assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1)); + + let huge: Vec = (0..10_000).map(f64::from).collect(); + let unusable = [ + vec![], + vec![10.0], + vec![0.0, w], // one column's worth for two columns + vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth + vec![0.0, 100.0, 90.0, w], // descending across pairs + vec![0.0, 130.0, 100.0, w], // an inverted gutter + vec![0.0, f64::NAN, 130.0, w], + vec![0.0, 100.0, f64::INFINITY, w], + vec![5.0, 100.0, 130.0, w], // not from the left margin + vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag + vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag + huge, + ]; + for bad in unusable { + p.columns.positions = Some(bad.clone()); + assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::>()); + assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::>()); + } + p.columns.positions = None; + assert_eq!(p.column_rects(), even); + + // A count outside 1..=216 has no valid positions, however many are given. + for count in [0, 217, u32::MAX] { + let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) }; + assert!(c.valid_positions(w).is_none(), "{count}"); + } + } + + /// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters + /// and the margins; the result is valid custom positions. + #[test] + fn with_gutter_moved_moves_a_gutter_pair_and_clamps() { + // Three columns across 450 with gutter 15: 0 140 155 295 310 450. + let c = Columns { count: 3, gutter: 15.0, positions: None }; + let even = c.edges(450.0); + assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]); + + assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0])); + // Past the left margin: the first column closes to nothing. + assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0])); + // Past the next gutter: the middle column closes to nothing, the next gutter stays put. + assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0])); + // The last gutter stops at the right margin. + assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0])); + + // It starts from custom positions already there, and its result is accepted as custom. + let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() }; + assert!(moved.valid_positions(450.0).is_some()); + assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0])); + + // No such gutter, or no position to move it to. + assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None); + assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None); + assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None); + } + + /// Mirroring reflects dragged columns across the margin box and keeps them ascending; an + /// invalid set becomes the even split. + #[test] + fn mirrored_columns_reflect_across_the_margin_box() { + let two = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + assert_eq!(two.mirrored(540.0), Columns { positions: Some(vec![0.0, 410.0, 440.0, 540.0]), ..two.clone() }); + assert_eq!(two.mirrored(540.0).mirrored(540.0), two); + + let three = Columns { count: 3, gutter: 10.0, positions: Some(vec![0.0, 50.0, 60.0, 200.0, 210.0, 300.0]) }; + let m = three.mirrored(300.0); + assert_eq!(m.positions, Some(vec![0.0, 90.0, 100.0, 240.0, 250.0, 300.0])); + assert!(m.valid_positions(300.0).is_some()); + + // Positions that do not fit the margin box, or none at all, mirror to the even split. + assert_eq!(two.mirrored(600.0).positions, None); + assert_eq!(two.mirrored(f64::NAN).positions, None); + let unsorted = Columns { positions: Some(vec![0.0, 130.0, 100.0, 540.0]), ..two.clone() }; + assert_eq!(unsorted.mirrored(540.0).positions, None); + let even = Columns { positions: None, ..two.clone() }; + assert_eq!(even.mirrored(540.0), even); + + // A left page takes a right page's columns mirrored, a page on the same side as they are. + let mut r = page(PageSide::Right, 612.0); + r.columns = Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 522.0]) }; + let l = page(PageSide::Left, 0.0); + assert_eq!(l.columns_from(&r).positions, Some(vec![0.0, 392.0, 422.0, 522.0])); + assert_eq!(page(PageSide::Right, 0.0).columns_from(&r), r.columns); + + // A left page holding them mirrored has the same margins as that right page (a one-page + // parent in a facing document); holding them unmirrored, it doesn't. + let mut l = page(PageSide::Left, 0.0); + l.columns = l.columns_from(&r); + assert!(r.same_margins(&l)); + l.columns = r.columns.clone(); + assert!(!r.same_margins(&l)); + } + #[test] fn spread_spine_and_hit() { let s = Spread { diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 2d28bf2c2..11c4e2ab1 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -2,9 +2,19 @@ use serde::{Deserialize, Serialize}; +use crate::edit::SpreadRef; use crate::ids::{ItemId, StoryId}; use crate::table::CellRange; +/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuideSel { + pub spread: SpreadRef, + pub page: usize, + pub index: usize, +} + /// A table cell: the table (in the selection's story) and the owning cell's grid position. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -83,6 +93,10 @@ pub struct Selection { /// The page selected with the Page tool (absolute index). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option, + /// Selected ruler guides. Never together with items, text or cells: selecting one clears + /// the other. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, } impl Selection { diff --git a/crates/doc/src/time.rs b/crates/doc/src/time.rs new file mode 100644 index 000000000..fb21b4b4b --- /dev/null +++ b/crates/doc/src/time.rs @@ -0,0 +1,74 @@ +//! Unix time → proleptic Gregorian calendar, exact for every `i64`. +//! +//! chrono is the only calendar logic. `NaiveDate` covers about ±262143 years, but clocks and files +//! can hold instants past that, so these extend its range: the Gregorian calendar repeats every +//! 400 years (146_097 days), so the instant is moved into chrono's range by whole cycles, chrono +//! gives the month, day and time of day, and the cycles are added back to the year (at most about +//! 2.5e16, so it fits an `i64`). Callers own only their output format and any year clamp. + +use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime}; + +/// Days since 1970-01-01 → (proleptic Gregorian year, a chrono date with the same month and day; +/// its own year is not the real one). +pub fn date_parts(days: i64) -> (i64, NaiveDate) { + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // 1970-01-01 is CE day 719_163; one cycle on from it is well inside chrono's range. + let d = NaiveDate::from_num_days_from_ce_opt(rest as i32 + 719_163).unwrap_or_default(); + (i64::from(d.year()) + cycles * 400, d) +} + +/// Unix seconds (+ nanos, clamped to 999_999_999) → (proleptic Gregorian year, a chrono date and +/// time with the same month, day and time of day; its own year is not the real one). +pub fn utc_parts(secs: i64, nanos: u32) -> (i64, NaiveDateTime) { + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (y, d) = date_parts(days); + let t = NaiveTime::from_num_seconds_from_midnight_opt(sod as u32, nanos.min(999_999_999)).unwrap_or_default(); + (y, d.and_time(t)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Timelike; + + fn ymdhms(secs: i64) -> (i64, u32, u32, u32, u32, u32) { + let (y, t) = utc_parts(secs, 0); + (y, t.month(), t.day(), t.hour(), t.minute(), t.second()) + } + + #[test] + fn epoch_and_ordinary_dates() { + assert_eq!(ymdhms(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(ymdhms(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(ymdhms(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + } + + #[test] + fn before_1970() { + assert_eq!(ymdhms(-1), (1969, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(-86_400), (1969, 12, 31, 0, 0, 0)); + assert_eq!(ymdhms(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + } + + #[test] + fn year_9999_rolls_into_10000() { + assert_eq!(ymdhms(253_402_300_799), (9999, 12, 31, 23, 59, 59)); + assert_eq!(ymdhms(253_402_300_800), (10000, 1, 1, 0, 0, 0)); + } + + #[test] + fn i64_extremes_stay_exact() { + assert_eq!(ymdhms(i64::MAX), (292277026596, 12, 4, 15, 30, 7)); + assert_eq!(ymdhms(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); + // Days (not seconds) at the extremes: whole cycles still fit, so the year can't overflow. + let ((hi, _), (lo, _)) = (date_parts(i64::MAX), date_parts(i64::MIN)); + assert!(hi > 25_000_000_000_000_000 && hi < i64::MAX && lo < -25_000_000_000_000_000 && lo > i64::MIN); + } + + #[test] + fn nanos_past_a_second_are_clamped() { + assert_eq!(utc_parts(0, 999_999_999).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, 1_500_000_000).1.nanosecond(), 999_999_999); + assert_eq!(utc_parts(0, u32::MAX).1.second(), 0); + } +} diff --git a/crates/doc/src/vars.rs b/crates/doc/src/vars.rs index 1ea4f8ced..c53eedb1a 100644 --- a/crates/doc/src/vars.rs +++ b/crates/doc/src/vars.rs @@ -126,28 +126,34 @@ pub fn now() -> i64 { } } -/// (year, month 1–12, day 1–31) from days since 1970-01-01 (H. Hinnant's public-domain algorithm). +/// (year, month 1–12, day 1–31) from days since 1970-01-01, exact for every `days` +/// (see [`crate::time::date_parts`]). fn civil(days: i64) -> (i64, u32, u32) { - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (yoe + era * 400 + i64::from(m <= 2), m, d) + use chrono::Datelike; + let (y, d) = crate::time::date_parts(days); + (y, d.month(), d.day()) } -/// Days since 1970-01-01 of a civil date (inverse of [`civil`]). +/// Days since 1970-01-01 of a civil date (inverse of [`civil`]), exact for any year. +/// +/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year within +/// its cycle and whole cycles are added back as days (an i128 so the extremes can't overflow), +/// which matches the proleptic Gregorian count the old hand-rolled code gave. 1970-01-01 is +/// `num_days_from_ce` 719_163. +/// +/// A day past the end of its month carries into the next one, as before chrono (`2019-02-30` +/// is 2019-03-02: `parse_iso_date` accepts any day 1..=31): chrono places the 1st of the month +/// and the rest of the days are added on. Only a month outside 1..=12 reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y.rem_euclid(400); - let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, 1)) + .and_then(|first| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&first)) - 719_163 + cycles * 146_097 + i128::from(d) - 1; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// Unix seconds of an ISO 8601 timestamp such as `2019-12-16T09:48:02+01:00` (seconds, the @@ -294,6 +300,30 @@ mod tests { assert_eq!(format_date(951_782_400, "yyyy-MM-dd"), "2000-02-29"); } + #[test] + fn dates_beyond_chrono_range() { + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; the clock can reach this, + // so keep the proleptic Gregorian date the old formula gave rather than the epoch, on both + // sides of the epoch. Values from the pre-chrono formula, through the public `format_date`. + assert_eq!(format_date(10_000_000_000_000, "yyyy-MM-dd HH:mm"), "318857-05-20 17:46"); + assert_eq!(format_date(-8_334_659_289_600, "yyyy-MM-dd"), "-262145-03-01"); + // `days_from_civil` is exact and inverts `civil` past the range, both sides. + for (y, m, d, days) in + [(262_144i64, 1u32, 1u32, 95_026_602i64), (300_000, 6, 15, 108_853_388), (-262_145, 3, 1, -96_465_964), (-300_000, 2, 29, -110_292_219)] + { + assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}"); + assert_eq!(civil(days), (y, m, d), "{days}"); + } + // The extremes must not overflow or panic (i128 intermediates). + let _ = (civil(i64::MAX), civil(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); + // A day past the end of its month carries over, as before chrono, instead of reading as 1970. + assert_eq!(days_from_civil(2019, 2, 30), days_from_civil(2019, 3, 2)); + assert_eq!(days_from_civil(2024, 2, 30), days_from_civil(2024, 3, 1)); + assert_eq!(days_from_civil(2019, 4, 31), days_from_civil(2019, 5, 1)); + assert_eq!(parse_iso_date("2019-02-30"), Some(1_551_484_800)); + assert_eq!(parse_iso_date("2019-02-30T09:48:02Z"), parse_iso_date("2019-03-02T09:48:02Z")); + } + #[test] fn iso_dates() { assert_eq!(parse_iso_date("2026-10-01T14:05:00Z"), Some(1_790_863_500)); diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index dc7127459..6d09f89dd 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } /// Text frames can't be anchored yet (their stories need frames on a spread). -fn check_item(it: &Item) -> Result<()> { +pub(crate) fn check_item(it: &Item) -> Result<()> { let mut ok = true; it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_))); if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) } diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index b29ae3b3c..38b543ff7 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -436,7 +436,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) { p.guides = from.guides.clone(); p.parent = from.parent; p.margins = from.margins; - p.columns = from.columns.clone(); + p.columns = p.columns_from(from); p.local_margins = from.local_margins; p.width = from.width; p.height = from.height; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ee963317..fb8848a90 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result> { Ok(Some(text)) } +/// Plain text for the system clipboard after copying objects: the copied text frames' stories, +/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the +/// copied objects", not as text. +pub fn copied_objects_text(s: &Session) -> String { + let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() }; + let mut stories = Vec::new(); + for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() { + it.walk(&mut |i| { + if let Some(tf) = i.text_frame() + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n"); + if text.is_empty() { '\u{FFFC}'.to_string() } else { text } +} + +/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and +/// it is their placeholder, so Paste stands for the copied objects. +fn is_objects_placeholder(s: &Session, text: &str) -> bool { + s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n") +} + /// Paste text at the insertion point: the formatted copy when the system clipboard still holds /// what was copied here (or `formatted` is forced), else plain text in the insertion format. +/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their +/// placeholder; other text was copied in another app since. Objects that can't be anchored (text +/// frames, into a table cell) paste as their placeholder text. fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?; let clip = s.text_clipboard.clone(); - // Items copied last (no text copied since) paste into the text as anchored objects. - if formatted - && clip.is_none() + let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x)); + if own && t.cell.is_none() && let Some(items) = s.clipboard.clone() { let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default(); - if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) { + if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) { return s.edit(|d, sel| { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); st.delete(r.start.min(st.len())..r.end.min(st.len())); - let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?; + // Fresh ids, story and asset copies for every paste, as on the page. + let mut stories = std::collections::HashMap::new(); + let fresh: Vec = list + .iter() + .map(|i| { + let mut i = i.clone(); + super::object::renumber(d, &items, &mut i, &mut stories); + if d.layer(i.layer).is_none() { + i.layer = d.default_layer(); + } + i + }) + .collect(); + let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "anchored": list.len()})) }); @@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< Ok(json!({"pos": end, "formatted": true})) }); } + // U+FFFC stands for objects with no text. + let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text }; let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; let text = outside_text(&text)?; s.execute("text.insert", &json!({"text": text, "raw": true})) @@ -256,28 +298,39 @@ pub fn specs() -> Vec { st.bump_revision(); ok() }), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| { - if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { - return super::text::delete_selection(s); - } - let ids = super::targets(s, p)?; - // With a placed graphic itself selected (Direct Selection), only the graphic goes: its - // frame stays, empty and selected (as in InDesign). Other items are deleted. - let content = s.doc()?.selection.content && p.get("ids").is_none(); - s.edit(|d, sel| { - let mut kept = Vec::new(); - for id in &ids { - if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { - it.content = designcraft_doc::Content::Unassigned; - kept.push(*id); - } else { - let _ = d.remove_item(*id); - } + cmd!( + "edit.clear", + "Clear", + ["Edit"], + Some("Delete"), + "{ids?} — delete selected items (or the selected text or ruler guides)", + has_doc, + |s, p| { + if s.doc()?.selection.text.is_some() && p.get("ids").is_none() { + return super::text::delete_selection(s); } - *sel = Selection::items(kept); - Ok(json!({"deleted": ids.len()})) - }) - }), + if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() { + return s.execute("guide.delete", &json!({})); + } + let ids = super::targets(s, p)?; + // With a placed graphic itself selected (Direct Selection), only the graphic goes: its + // frame stays, empty and selected (as in InDesign). Other items are deleted. + let content = s.doc()?.selection.content && p.get("ids").is_none(); + s.edit(|d, sel| { + let mut kept = Vec::new(); + for id in &ids { + if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { + it.content = designcraft_doc::Content::Unassigned; + kept.push(*id); + } else { + let _ = d.remove_item(*id); + } + } + *sel = Selection::items(kept); + Ok(json!({"deleted": ids.len()})) + }) + } + ), cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| { if let Some(text) = copy_text(s)? { return Ok(json!({"text": text})); @@ -316,7 +369,8 @@ pub fn specs() -> Vec { if s.doc()?.selection.text.is_some() { return paste_text(s, text.map(str::to_string), true); } - if let Some(text) = text { + // The placeholder of an object copy pastes the copied objects. + if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) { // What our own text copy put on the clipboard keeps its formatting. let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); return paste_text_frame(s, p, text, slice.as_deref()); @@ -527,6 +581,60 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). + #[test] + fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); + } + + /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. + #[test] + fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn outside_text_never_pastes_markers() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 9655432ad..c9e847ccd 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -744,11 +744,11 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I } /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. -fn rasterize_layered(d: &designcraft_doc::Document) -> (Option, usize) { +fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { let ids: Vec = d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); if ids.is_empty() { - return (None, 0); + return Ok((None, 0)); } let mut out = d.clone(); let mut n = 0; @@ -763,7 +763,8 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option (Option Result

\n\n", xml_text(&title) ); - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(&id), - xml_text(&title) + let rendition = format!( + "\npre-paginated\nauto\n" ); + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); + let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); + write_package(&opf, &nav, entries) +} + +/// The package metadata both EPUB flavours share. +struct PackageHead<'a> { + lang: &'a str, + id: &'a str, + title: &'a str, + author: Option<&'a str>, + modified: &'a str, +} + +/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after +/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. +fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { + let PackageHead { lang, id, title, author, modified } = h; + let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); + format!( + "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", + xml_text(id), + xml_text(title) + ) +} + +/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order +/// as (path, bytes, stored uncompressed). +fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { + let io = |e: std::io::Error| EpubError(e.to_string()); + let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); { @@ -246,9 +267,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } - for (name, data) in &files { - let o = if name.ends_with(".png") { stored } else { deflate }; - z.start_file(name.as_str(), o).map_err(zerr)?; + for (name, data, store) in entries { + z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; z.write_all(data).map_err(io)?; } z.finish().map_err(zerr)?; @@ -1366,8 +1386,6 @@ fn base64(data: &[u8]) -> String { /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); @@ -1428,56 +1446,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", - xml_text(&id), - xml_text(&title) - ); - let container = "\n\n"; - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(zerr)?; - z.write_all(b"application/epub+zip").map_err(io)?; - for (name, data) in [ - ("META-INF/container.xml", container.as_bytes()), - ("OEBPS/content.opf", opf.as_bytes()), - ("OEBPS/nav.xhtml", nav.as_bytes()), - ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), - ] { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, c) in chapters.iter().enumerate() { - z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?; - z.write_all(c.as_bytes()).map_err(io)?; - } - for (name, (_, data)) in &images { - z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (k, f) in opts.fonts.iter().enumerate() { - z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; - z.write_all(&f.data).map_err(io)?; - } - if let Some(c) = &opts.cover { - z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; - z.write_all(c).map_err(io)?; - let xhtml = format!( - "\n\n\n{}\n\"{}\"/\n\n", - xml_text(&title), - cover_file(c).0, - xml_attr(&title) - ); - z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; - z.write_all(xhtml.as_bytes()).map_err(io)?; - } - z.finish().map_err(zerr)?; + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); + let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); + let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; + entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); + entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); + entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); + let cover_xhtml = opts.cover.as_ref().map(|c| { + format!( + "\n\n\n{}\n\"{}\"/\n\n", + xml_text(&title), + cover_file(c).0, + xml_attr(&title) + ) + }); + if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { + entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); + entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); } - Ok(buf.into_inner()) + write_package(&opf, &nav, entries) } #[cfg(test)] @@ -1582,6 +1570,14 @@ mod tests { (951_782_400, "2000-02-29T00:00:00Z"), (1_791_891_000, "2026-10-13T11:30:00Z"), (-86_400, "1969-12-31T00:00:00Z"), + // Years past 9999 keep the plain zero-padded year (no `+`). + (253_402_300_800, "10000-01-01T00:00:00Z"), + // Beyond chrono's range the old `civil_from_days` still converted; pin that output + // (never the epoch) in both directions and at the `i64` extremes. + (10_000_000_000_000, "318857-05-20T17:46:40Z"), + (-10_000_000_000_000, "-314918-08-13T06:13:20Z"), + (i64::MAX, "292277026596-12-04T15:30:07Z"), + (i64::MIN, "-292277022657-01-27T08:29:52Z"), ] { assert_eq!(iso8601(t), want); } diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/it/export_tag_styles.rs similarity index 100% rename from crates/epub/tests/export_tag_styles.rs rename to crates/epub/tests/it/export_tag_styles.rs diff --git a/crates/epub/tests/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs similarity index 100% rename from crates/epub/tests/grouped_reading_order.rs rename to crates/epub/tests/it/grouped_reading_order.rs diff --git a/crates/epub/tests/it/main.rs b/crates/epub/tests/it/main.rs new file mode 100644 index 000000000..69af15eaa --- /dev/null +++ b/crates/epub/tests/it/main.rs @@ -0,0 +1,2 @@ +mod export_tag_styles; +mod grouped_reading_order; diff --git a/crates/fonts/tests/it/main.rs b/crates/fonts/tests/it/main.rs new file mode 100644 index 000000000..1737c00b9 --- /dev/null +++ b/crates/fonts/tests/it/main.rs @@ -0,0 +1,4 @@ +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod system_fonts; diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/it/system_fonts.rs similarity index 86% rename from crates/fonts/tests/system_fonts.rs rename to crates/fonts/tests/it/system_fonts.rs index 62310c34b..66db0f5b1 100644 --- a/crates/fonts/tests/system_fonts.rs +++ b/crates/fonts/tests/it/system_fonts.rs @@ -1,7 +1,5 @@ //! A new session finds the installed system fonts by family name (#22). One test in its own //! process, so the process-wide database starts fresh, as it does when the app starts. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use designcraft_fonts::{FontDb, system_font_dirs}; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index ddda022e7..e9e844d6e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -8,7 +8,7 @@ use designcraft_doc::{ CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story, TextFrameOptions, story as st, }; -use designcraft_geom::{Affine, PathData, Rect}; +use designcraft_geom::{Affine, PathData}; use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt}; use crate::xml::{El, Node, document, document_with_pi}; @@ -776,7 +776,7 @@ impl<'a> Ex<'a> { }, ) .attr("CreatePrimaryTextFrame", bool_s(s.primary_text_frame)) - .attr("ColumnGuideLocked", "true") + .attr("ColumnGuideLocked", bool_s(s.column_guides_locked)) .attr("MasterTextFrame", bool_s(s.primary_text_frame)) .attr("SnippetImportUsesOriginalLocation", "false"), ); @@ -2908,8 +2908,9 @@ impl Group { fn margin_el(pg: &designcraft_doc::Page) -> El { let m = pg.margin_rect(); - let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter); - let pos: Vec = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect(); + // Dragged column guides that still fit the margin box are written as they stand, so they + // survive the round trip; otherwise the even split, which InDesign writes for unmoved columns. + let pos: Vec = pg.columns.edges(m.width()).into_iter().map(num).collect(); El::new("MarginPreference") .attr("ColumnCount", pg.columns.count.max(1)) .attr("ColumnGutter", num(pg.columns.gutter)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b707b719f..c8e02f12a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -850,6 +850,9 @@ impl<'r> Importer<'r> { if let Some(v) = e.boolean("CreatePrimaryTextFrame") { s.primary_text_frame = v; } + if let Some(v) = e.boolean("ColumnGuideLocked") { + s.column_guides_locked = v; + } } fn grid_prefs(&mut self, e: &El) { @@ -2550,9 +2553,15 @@ impl<'r> Importer<'r> { .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { - (Some(m), _) => margins_of(m), + (Some(m), _) => { + let (margins, columns) = margins_of(m); + (margins, fit_columns(columns, &margins, r.width())) + } (None, Some(own)) => own.clone(), - (None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())), + (None, None) => { + let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())); + (margins, fit_columns(columns, &margins, r.width())) + } }; let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns); out_pages.push(Page { @@ -3481,10 +3490,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer { fn margins_of(m: &El) -> (Margins, Columns) { let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); - ( - Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, - ) + let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }; + let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32; + let gutter = g("ColumnGutter", 12.0); + let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter)); + (margins, Columns { count, gutter, positions }) +} + +/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count` +/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`] +/// over the box they span. InDesign writes the attribute whether or not they have been moved, so +/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the +/// columns against a later change of count or gutter. Anything malformed is the even split; at +/// most one number past `2 × count` is parsed, so a long list costs nothing. +fn custom_columns(s: &str, count: u32, gutter: f64) -> Option> { + let want = 2 * count as usize; + let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::().ok()).collect::>>()?; + let width = *pos.last()?; + if !designcraft_doc::valid_column_positions(&pos, count, width) { + return None; + } + let even = Columns { count, gutter, positions: None }.edges(width); + // The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more. + pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos) +} + +/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page +/// with these margins (the box's width doesn't depend on which side of the spine it is). +fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns { + let width = (page_width - margins.inside - margins.outside).max(0.0); + if columns.valid_positions(width).is_none() { + columns.positions = None; + } + columns } /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. @@ -4011,6 +4049,48 @@ mod page_column_import_safety_tests { assert_eq!(margins, Margins::uniform(36.)); } } + + /// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long + /// list is not parsed past what `ColumnCount` allows (#1096). + #[test] + fn hostile_column_positions_fall_back_to_the_even_split() { + let positions = |count: &str, raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.])); + let huge = (0..10_000).map(|i| i.to_string()).collect::>().join(" "); + for (count, raw) in [ + ("2", "0 215 235 450"), // the even split + ("2", "0 150 170"), // odd + ("2", "0 450"), // too few for the count + ("2", "0 100 120 200 220 450"), // too many for the count + ("2", "0 150 90 450"), // descending + ("2", "0 NaN 170 450"), + ("2", "0 inf 170 450"), + ("2", "0 150 170 1e309"), + ("2", "0 x 170 450"), + ("2", "10 150 170 450"), // not from the margin + ("2", ""), + ("2", huge.as_str()), + ("4000000000", huge.as_str()), // the count is capped at 216, so is the list + ("216", huge.as_str()), + ("NaN", "0 150 170 450"), + ] { + assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or("")); + } + // The cap itself: 216 one-point columns with no gutter are the even split; one edge moved + // makes them custom. + let gapless = |raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + let edges = |first_right: &str| { + (0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::>().join(" ") + }; + assert_eq!(gapless(&edges("1")), None); + assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432)); + } } #[cfg(test)] diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 38c4fabbe..8f7b22ff9 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -17,7 +17,8 @@ //! ## Supported subset //! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph, //! character and object styles (with `BasedOn` and style groups), fonts, document preferences -//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections, +//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions, +//! units, grids), ruler guides, layers, sections, //! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`, //! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text //! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 63bff61f5..434d54fb1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2798,6 +2798,18 @@ fn round_trips_the_zero_point_and_ruler_origin() { assert_eq!(fixture("NaN 4").settings.zero_point, [0.0, 0.0]); } +#[test] +fn round_trips_lock_column_guides() { + let mut d = Document::new(&NewDocument::default()); + assert!(zip_text(&export_idml(&d), "Resources/Preferences.xml").contains(r#"ColumnGuideLocked="true""#)); + assert!(import_idml(&export_idml(&d)).unwrap().settings.column_guides_locked); + d.settings.column_guides_locked = false; + let bytes = export_idml(&d); + let prefs = zip_text(&bytes, "Resources/Preferences.xml"); + assert!(prefs.contains(r#"ColumnGuideLocked="false""#), "{prefs}"); + assert!(!import_idml(&bytes).unwrap().settings.column_guides_locked); +} + #[test] fn kenten_shatai_and_grid_settings_import_and_round_trip() { use designcraft_doc::cjk::CharacterAlignment; @@ -4219,6 +4231,128 @@ mod liquid_page_rules { } } +/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set — +/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096). +#[test] +fn custom_column_positions_import_and_round_trip() { + // Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and + // starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450". + let with = |cols: &str| { + let spread = SPREAD.replace( + r#""#, + &format!( + r#""# + ), + ); + import_idml_with( + &zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]), + &|_| None, + ) + .unwrap() + }; + let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::>(); + + // Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it. + let d = with(r#"ColumnsPositions="0 150 170 450""#); + assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]); + + // The even set is dropped, so a later change of count or gutter is not frozen out. The + // geometry is the same either way, which is what makes keeping it pointless. + let even = with(r#"ColumnsPositions="0 215 235 450""#); + assert_eq!(even.spreads[0].pages[0].columns.positions, None); + assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]); + // Including when InDesign's own arithmetic leaves the last bits different. + assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None); + + // Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a + // count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and + // a list far longer than any count allows. Each page keeps the even split. + let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::>().join(" ")); + for bad in [ + r#"ColumnsPositions="0 150 170""#, + r#"ColumnsPositions="0 100 120 200 220 450""#, + r#"ColumnsPositions="0 150 90 450""#, + r#"ColumnsPositions="0 x 170 450""#, + r#"ColumnsPositions="0 NaN 170 450""#, + r#"ColumnsPositions="0 150 170 400""#, + r#"ColumnsPositions="""#, + huge.as_str(), + ] { + let d = with(bad); + assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or("")); + assert_eq!(rects(&d), rects(&even)); + } + + // And they come back from an export, so the round trip keeps the dragged gutter. + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&back), rects(&d)); + // A page nobody dragged still exports the even split, not a frozen copy of it. + assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None); +} + +/// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes +/// it (a `` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex` +/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it. +#[test] +fn ruler_guide_in_a_column_gutter_imports_without_facing_pages() { + use designcraft_doc::Orientation::{Horizontal, Vertical}; + let package = |view: &str| { + let prefs = + PREFS.replace(r#"FacingPages="true""#, r#"FacingPages="false""#).replace("", &format!("{view}")); + let spread = SPREAD.replace( + r#" + + "#, + r#" + UseMasterColor + + + + Cyan + + + Cyan + + "#, + ); + assert_ne!(spread, SPREAD, "the fixture's first page was replaced"); + import_idml(&zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ])) + .unwrap() + }; + let guides = + |d: &Document| d.spreads[0].pages[0].guides.iter().map(|g| (g.orientation, g.position, g.spread, g.layer.is_some())).collect::>(); + // The first page starts at x 0 in our spread coordinates, so the spread's and the page's ruler + // origins agree; no ViewPreference means InDesign's default, the spread. + for view in ["", r#""#, r#""#] { + let first = package(view); + assert!(!first.settings.facing_pages); + assert_eq!(guides(&first), [(Vertical, 180.0, false, true), (Horizontal, 300.0, true, true)], "{view:?}"); + // The guide sits between the page's left margin (20) and right margin (470). + let m = first.spreads[0].pages[0].margin_rect(); + assert!(m.x0 < 180.0 && 180.0 < m.x1); + let back = import_idml(&export_idml(&first)).unwrap(); + assert_eq!(guides(&back), guides(&first), "round trip, {view:?}"); + } +} + /// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body /// region (cells with `[None]` take it from there) and overridden on one cell. const FIRST_BASELINE_STYLES: &str = r#" diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 61b9aff7d..1723145ea 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> { return Some(id); } } - // A family (or generic family) that matches: usvg's own selection. + // A family (or generic family) that matches: the query below. _ => break, } } diff --git a/crates/images/tests/it/main.rs b/crates/images/tests/it/main.rs new file mode 100644 index 000000000..d2f76fb71 --- /dev/null +++ b/crates/images/tests/it/main.rs @@ -0,0 +1 @@ +mod rgba_conversion_contract; diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/it/rgba_conversion_contract.rs similarity index 100% rename from crates/images/tests/rgba_conversion_contract.rs rename to crates/images/tests/it/rgba_conversion_contract.rs diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs similarity index 99% rename from crates/mcp/tests/automation_roots.rs rename to crates/mcp/tests/it/automation_roots.rs index 9380b1987..871945920 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/it/automation_roots.rs @@ -3,8 +3,6 @@ //! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the //! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing //! is written outside. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs new file mode 100644 index 000000000..be744719e --- /dev/null +++ b/crates/mcp/tests/it/main.rs @@ -0,0 +1,4 @@ +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod automation_roots; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 82e71ef0f..2b4275da3 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -22,6 +22,7 @@ designcraft-images = { workspace = true } image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } # The placed-PDF version check names hayro's PdfVersion (krilla::pdf::Pdf::version returns it). hayro-syntax = "0.7.2" diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index bd7440faa..bdfe98e75 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -457,20 +457,14 @@ fn date_time(t: i64) -> krilla::metadata::DateTime { .utc_offset_minute(0) } -/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. +/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. A time +/// outside chrono's year range still converts (the calendar repeats every 400 years, so the +/// instant is moved into range by whole cycles and only the year is carried, matching the old +/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - (year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8) + use chrono::{Datelike, Timelike}; + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } /// A successful conversion and its name-independent diagnostic. Replayed once for each asset. @@ -2244,4 +2238,19 @@ mod eps_tests { assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}"); } } + + #[test] + fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() { + use super::civil; + assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + // Beyond chrono's year range the old `civil` still converted; pin that output, never the + // epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards. + assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0)); + assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40)); + assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20)); + assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7)); + assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52)); + } } diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs deleted file mode 100644 index dd3d413d0..000000000 --- a/crates/pdf/tests/image_content_cache.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Run public contracts with the production content-cache hasher. -#[path = "support/image_content_cache_contract.rs"] -mod contracts; -#[path = "support/image_fixtures.rs"] -mod image_fixtures; diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/it/gradient_color_spaces.rs similarity index 98% rename from crates/pdf/tests/gradient_color_spaces.rs rename to crates/pdf/tests/it/gradient_color_spaces.rs index 01cd64b85..15a2c5233 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/it/gradient_color_spaces.rs @@ -42,7 +42,7 @@ fn mixed_gradient_export_survives_in_subprocess() { const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD"; if std::env::var_os(CHILD).is_none() { let out = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"]) + .args(["--exact", "gradient_color_spaces::mixed_gradient_export_survives_in_subprocess", "--nocapture"]) .env(CHILD, "1") .output() .unwrap(); diff --git a/crates/pdf/tests/it/image_content_cache.rs b/crates/pdf/tests/it/image_content_cache.rs new file mode 100644 index 000000000..ec0f666c0 --- /dev/null +++ b/crates/pdf/tests/it/image_content_cache.rs @@ -0,0 +1,3 @@ +//! Run public contracts with the production content-cache hasher. +#[path = "../support/image_content_cache_contract.rs"] +mod contracts; diff --git a/crates/pdf/tests/it/main.rs b/crates/pdf/tests/it/main.rs new file mode 100644 index 000000000..053f62183 --- /dev/null +++ b/crates/pdf/tests/it/main.rs @@ -0,0 +1,7 @@ +#[path = "../support/image_fixtures.rs"] +mod image_fixtures; + +mod gradient_color_spaces; +mod image_content_cache; +mod page_entry_search_contract; +mod spot_gradients; diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/it/page_entry_search_contract.rs similarity index 100% rename from crates/pdf/tests/page_entry_search_contract.rs rename to crates/pdf/tests/it/page_entry_search_contract.rs diff --git a/crates/pdf/tests/spot_gradients.rs b/crates/pdf/tests/it/spot_gradients.rs similarity index 100% rename from crates/pdf/tests/spot_gradients.rs rename to crates/pdf/tests/it/spot_gradients.rs diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml index c6e1c876f..afc90c69c 100644 --- a/crates/pptx/Cargo.toml +++ b/crates/pptx/Cargo.toml @@ -19,6 +19,7 @@ skrifa = { workspace = true } image = { workspace = true } thiserror = { workspace = true } zip = { workspace = true } +chrono = { workspace = true } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs index ed5ad553b..7e9e123e0 100644 --- a/crates/pptx/src/package.rs +++ b/crates/pptx/src/package.rs @@ -290,20 +290,14 @@ fn core(p: &Package) -> String { s } -/// Unix seconds → UTC civil date and time (proleptic Gregorian). +/// Unix seconds → UTC civil date and time (proleptic Gregorian), year clamped to the W3CDTF +/// `0..=9999` range. A time outside chrono's year range still converts (the calendar repeats every +/// 400 years, so the instant is moved into range by whole cycles and only the year is carried, +/// matching the old `civil`), then the year is clamped — never a silent reset to the epoch. fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = yoe + era * 400 + i64::from(m <= 2); - (y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32) + use chrono::{Datelike, Timelike}; + let (y, t) = designcraft_doc::time::utc_parts(t, 0); + (y.clamp(0, 9999), t.month(), t.day(), t.hour(), t.minute(), t.second()) } /// A neutral theme: text sets its own fonts and colours, so these only matter for new objects. @@ -331,5 +325,13 @@ mod tests { assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + // Beyond chrono's year range the old `civil` still converted and clamped only the year to + // the W3CDTF `0..=9999` range; pin that (month/day/time keep the real instant), never the + // epoch, in both directions and at the `i64` extremes. + assert_eq!(civil(253_402_300_800), (9999, 1, 1, 0, 0, 0)); + assert_eq!(civil(10_000_000_000_000), (9999, 5, 20, 17, 46, 40)); + assert_eq!(civil(-10_000_000_000_000), (0, 8, 13, 6, 13, 20)); + assert_eq!(civil(i64::MAX), (9999, 12, 4, 15, 30, 7)); + assert_eq!(civil(i64::MIN), (0, 1, 27, 8, 29, 52)); } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5ababd51e..8cd446af1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -17,9 +17,10 @@ designcraft-compose = { workspace = true } vello_cpu = { workspace = true } vello_common = { workspace = true } image = { workspace = true } -hayro = { workspace = true } +craft-pdf = { workspace = true, features = ["raster"] } designcraft-images = { workspace = true } log = { workspace = true } +thiserror = { workspace = true } serde = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c42d8c897..6823fc006 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1235,29 +1235,93 @@ pub fn is_pdf(bytes: &[u8]) -> bool { /// Page count of a PDF. pub fn pdf_page_count(bytes: &[u8]) -> Option { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; Some(pdf.pages().len()) } /// Size of PDF page `page` (0-based), in points, as it shows (crop box, rotation applied). pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); Some((w as f64, h as f64)) } -/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off; -/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given. +/// A placed page could not be rasterized completely for export. +#[derive(Debug, thiserror::Error)] +pub enum PdfRasterError { + #[error("{0}")] + Input(#[source] craft_pdf::OpenError), + #[error("{0}")] + Render(#[from] craft_pdf::raster::Error), + #[error("the PDF page could not be read or rendered")] + InvalidPage, + #[error("PDF page {page} is unavailable; choose an existing page in Image Import Options or place the replacement PDF again")] + MissingPage { page: usize }, + #[error("the selected PDF layers could not be hidden")] + LayerRewrite, + #[error("an image could not be decoded or exceeded the PDF renderer's resource limits; repair or replace this placed PDF before exporting again")] + ImageDecode, + #[error("the PDF renderer encountered an unsupported font")] + UnsupportedFont, + #[error( + "the PDF renderer could not read all page content or reached its resource limits; repair or replace this placed PDF before exporting again" + )] + ContentTruncated, +} + +/// Reject oversized PDFs before placement, layer rewriting or output allocation. +pub fn check_pdf_input_size(size: usize) -> Result<(), PdfRasterError> { + craft_pdf::check_input_size(size).map_err(PdfRasterError::Input) +} + +/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off. +/// Only the `shown` part ([`designcraft_doc::Asset::shown_box`]) is kept when given. +/// Returns `None` on the same failures as [`pdf_page_png_checked`]. pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option> { - let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) }; - let pm = render_pdf_page(&data, page, max_side)?; + pdf_page_png_checked(bytes, page, max_side, hidden, shown).ok() +} + +/// Rasterize a placed page for export, preserving available interpreter diagnostics. +/// This cannot detect backend approximations that do not emit an interpreter warning. +pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result, PdfRasterError> { + check_pdf_input_size(bytes.len())?; + let data = if hidden.is_empty() { + std::borrow::Cow::Borrowed(bytes) + } else { + std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?) + }; + let raster = render_pdf_raster(&data, page, max_side)?; + if raster.diagnostics.image_decode_failed { + return Err(PdfRasterError::ImageDecode); + } + if raster.diagnostics.unsupported_font { + return Err(PdfRasterError::UnsupportedFont); + } + if raster.diagnostics.content_truncated { + return Err(PdfRasterError::ContentTruncated); + } + let pm = raster_pixmap(&raster); let pm = match shown { - Some(frac) => crop_pixmap(&pm, frac)?, + Some(frac) => crop_pixmap(&pm, frac).ok_or(PdfRasterError::InvalidPage)?, None => pm, }; - let pixels: Vec = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); - Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png()) + let pixels = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); + Ok(Rendered { width: u32::from(pm.width()), height: u32::from(pm.height()), pixels }.to_png()) +} + +/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display). +/// Export callers that need failure diagnostics use [`pdf_page_png_checked`]. +pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { + #[cfg(test)] + PDF_RENDERS.with(|n| n.set(n.get() + 1)); + let raster = render_pdf_raster(bytes, page, max_side).ok()?; + Some(raster_pixmap(&raster)) +} + +fn raster_pixmap(raster: &craft_pdf::raster::Raster) -> Pixmap { + let data = raster.bytes().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect(); + Pixmap::from_parts(data, raster.width as u16, raster.height as u16) } #[cfg(test)] @@ -1266,21 +1330,46 @@ thread_local! { static PDF_RENDERS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF -/// export embeds the page as vectors). -pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { - #[cfg(test)] - PDF_RENDERS.with(|n| n.set(n.get() + 1)); - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; - let p = pdf.pages().get(page)?; +fn render_pdf_raster(bytes: &[u8], page: usize, max_side: u32) -> Result { + let pdf = craft_pdf::open_slice(bytes, None).map_err(|e| match e { + craft_pdf::OpenError::Load(_) => PdfRasterError::InvalidPage, + other => PdfRasterError::Input(other), + })?; + let p = pdf.pages().get(page).ok_or(PdfRasterError::MissingPage { page: page.saturating_add(1) })?; let (w, h) = p.render_dimensions(); let scale = (max_side as f32 / w.max(h).max(1.0)).min(8.0); - let rs = hayro::RenderSettings { x_scale: scale, y_scale: scale, ..Default::default() }; - let pm = hayro::render(p, &hayro::RenderCache::new(), &hayro::hayro_interpret::InterpreterSettings::default(), &rs); - let (pw, ph) = (pm.width(), pm.height()); - let data: Vec = - pm.data_as_u8_slice().as_chunks::<4>().0.iter().map(|c| vello_cpu::color::PremulRgba8 { r: c[0], g: c[1], b: c[2], a: c[3] }).collect(); - Some(Pixmap::from_parts(data, pw, ph)) + craft_pdf::raster::render( + p, + &craft_pdf::raster::RenderCache::new(), + &craft_pdf::interpret::InterpreterSettings::default(), + &craft_pdf::raster::Options { scale: [scale; 2], ..Default::default() }, + ) + .map_err(PdfRasterError::Render) +} + +/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points +/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the +/// crop box, as PDF readers do. +pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> { + use craft_pdf::syntax::object::{Rect as PRect, dict::keys}; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; + let p = pdf.pages().get(page)?; + let visible = p.intersected_crop_box(); + let page_box = match kind { + "media" => p.media_box(), + "trim" | "bleed" | "art" => { + let key: &[u8] = match kind { + "trim" => keys::TRIM_BOX, + "bleed" => keys::BLEED_BOX, + _ => keys::ART_BOX, + }; + p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible) + } + _ => visible, + }; + let transform = Affine::new(p.initial_transform(true).as_coeffs()); + let b = transform.transform_rect_bbox(Rect::new(page_box.x0, page_box.y0, page_box.x1, page_box.y1)); + b.is_finite().then_some((b.x0, b.y0, b.width(), b.height())) } /// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as @@ -1289,10 +1378,10 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option Option<([f64; 4], (f64, f64))> { + use craft_pdf::syntax::object::{Rect as PRect, dict::keys}; use designcraft_doc::PdfCrop; - use hayro::hayro_syntax::object::{Rect as PRect, dict::keys}; const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0]; - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); let (w, h) = (f64::from(w), f64::from(h)); diff --git a/crates/render/src/pdf_bounds.rs b/crates/render/src/pdf_bounds.rs index 74665ae3c..17881df6e 100644 --- a/crates/render/src/pdf_bounds.rs +++ b/crates/render/src/pdf_bounds.rs @@ -2,8 +2,8 @@ //! of every painted path, glyph and image, each cut to the clip in force when it's painted. //! Nothing is rasterized, so measuring a page costs a fraction of drawing it. -use hayro::hayro_interpret::font::Glyph; -use hayro::hayro_interpret::{ +use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::{ BlendMode, ClipPath, Context, Device, FillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, TransformExt, interpret_page, }; @@ -12,7 +12,7 @@ use vello_cpu::kurbo::{self, Affine, BezPath, Rect, Shape}; /// The bounds of what PDF page `page` draws, as fractions of the page as rendered (its visible /// crop box, rotated as it displays): left, top, right, bottom. `None` when it draws nothing. pub(crate) fn page_content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> { - let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let p = pdf.pages().get(page)?; let (w, h) = p.render_dimensions(); let (w, h) = (f64::from(w), f64::from(h)); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index 344585e3b..bc6d864a7 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use hayro::hayro_syntax::Pdf; -use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; /// (object number, name, on by default) of each layer. fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { @@ -28,7 +28,7 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .filter_map(|r| { let d = xref.get::>(ObjectIdentifier::from(r))?; let name = d - .get::>(b"Name") + .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; @@ -39,13 +39,13 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { /// The layers of a placed PDF: (name, visible by default). pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { - Pdf::new(Arc::new(bytes.to_vec())).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() + craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } /// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file /// has no such layers or can't be read. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { - let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?; + let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; @@ -74,8 +74,8 @@ pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { ); let xref_at = out.len(); let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string(); - tail.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? + let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); + tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? }; out.extend_from_slice( format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/it/blend_over_paper.rs similarity index 100% rename from crates/render/tests/blend_over_paper.rs rename to crates/render/tests/it/blend_over_paper.rs diff --git a/crates/render/tests/colorize.rs b/crates/render/tests/it/colorize.rs similarity index 100% rename from crates/render/tests/colorize.rs rename to crates/render/tests/it/colorize.rs diff --git a/crates/render/tests/effects.rs b/crates/render/tests/it/effects.rs similarity index 99% rename from crates/render/tests/effects.rs rename to crates/render/tests/it/effects.rs index a9420fbcb..230913173 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/it/effects.rs @@ -3,7 +3,7 @@ //! //! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur //! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended -//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test effects`. +//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`. use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; diff --git a/crates/render/tests/glyph_retention.rs b/crates/render/tests/it/glyph_retention.rs similarity index 100% rename from crates/render/tests/glyph_retention.rs rename to crates/render/tests/it/glyph_retention.rs diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/it/gradient_inks.rs similarity index 100% rename from crates/render/tests/gradient_inks.rs rename to crates/render/tests/it/gradient_inks.rs diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/it/gradients.rs similarity index 100% rename from crates/render/tests/gradients.rs rename to crates/render/tests/it/gradients.rs diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..52000ae2d --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,12 @@ +mod blend_over_paper; +mod colorize; +mod effects; +mod glyph_retention; +mod gradient_inks; +mod gradients; +mod pdf_geometry; +mod perf; +mod placed_pdf; +mod stroke_alignment; +mod text_frame_bottom; +mod text_stroke_behind; diff --git a/crates/render/tests/it/pdf_geometry.rs b/crates/render/tests/it/pdf_geometry.rs new file mode 100644 index 000000000..dcf9427e2 --- /dev/null +++ b/crates/render/tests/it/pdf_geometry.rs @@ -0,0 +1,61 @@ +fn page(width: f32, height: f32) -> Vec { + let content = format!("1 0 0 rg 0 0 {width} {height} re f"); + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() +} + +#[test] +fn thin_pdf_page_has_visible_preview_and_export_pixels() { + let bytes = page(1000.0, 0.2); + let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap(); + assert_eq!((preview.width(), preview.height()), (700, 1)); + assert!(preview.data().iter().any(|pixel| pixel.a > 0)); + let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap(); + let image = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), (700, 1)); + assert!(image.pixels().any(|pixel| pixel[3] > 0)); +} + +#[test] +fn empty_media_box_preserves_the_parsers_default_page_repair() { + let bytes = page(0.0, 0.0); + let missing = String::from_utf8(bytes.clone()).unwrap().replace("/MediaBox [0 0 0 0]", ""); + let size = designcraft_render::pdf_page_size(&bytes, 0).unwrap(); + assert!(size.0 > 0.0 && size.1 > 0.0); + assert_eq!(Some(size), designcraft_render::pdf_page_size(missing.as_bytes(), 0)); + let preview = designcraft_render::render_pdf_page(&bytes, 0, 700).unwrap(); + let png = designcraft_render::pdf_page_png_checked(&bytes, 0, 700, &[], None).unwrap(); + let image = image::load_from_memory(&png).unwrap(); + assert_eq!((image.width(), image.height()), (u32::from(preview.width()), u32::from(preview.height()))); +} + +#[test] +fn pdf_input_limit_is_actionable_before_placement() { + let error = designcraft_render::check_pdf_input_size(craft_pdf::MAX_INPUT_BYTES + 1).unwrap_err(); + assert!(matches!(error, designcraft_render::PdfRasterError::Input(_))); + assert!(error.to_string().contains("input limit")); +} + +#[test] +fn pdf_boxes_match_scaled_and_rotated_page_coordinates() { + for (rotation, size, trim, media) in [ + (0, (200.0, 120.0), (20.0, 40.0, 140.0, 60.0), (-20.0, -40.0, 400.0, 200.0)), + (90, (120.0, 200.0), (20.0, 20.0, 60.0, 140.0), (-40.0, -20.0, 200.0, 400.0)), + (180, (200.0, 120.0), (40.0, 20.0, 140.0, 60.0), (-180.0, -40.0, 400.0, 200.0)), + (270, (120.0, 200.0), (40.0, 40.0, 60.0, 140.0), (-40.0, -180.0, 200.0, 400.0)), + ] { + let bytes = String::from_utf8(page(200.0, 100.0)).unwrap().replace( + "/MediaBox [0 0 200 100]", + &format!("/MediaBox [0 0 200 100] /CropBox [10 20 110 80] /TrimBox [20 30 90 60] /UserUnit 2 /Rotate {rotation}"), + ); + let bytes = bytes.as_bytes(); + assert_eq!(designcraft_render::pdf_page_size(bytes, 0), Some(size), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "crop"), Some((0.0, 0.0, size.0, size.1)), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "trim"), Some(trim), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "media"), Some(media), "rotation {rotation}"); + assert_eq!(designcraft_render::pdf_page_box(bytes, 0, "art"), Some((0.0, 0.0, size.0, size.1)), "missing box, rotation {rotation}"); + } +} diff --git a/crates/render/tests/perf.rs b/crates/render/tests/it/perf.rs similarity index 98% rename from crates/render/tests/perf.rs rename to crates/render/tests/it/perf.rs index 1f23f88f4..46f33ab62 100644 --- a/crates/render/tests/perf.rs +++ b/crates/render/tests/it/perf.rs @@ -1,6 +1,6 @@ //! Renderer caching/culling behaviour, and a render-time regression test (`#[ignore]`: wall-clock //! timings are only meaningful on an idle machine — run with -//! `cargo test --release -p designcraft-render --test perf -- --ignored`). +//! `cargo test --release -p designcraft-render --test it perf:: -- --ignored`). use std::time::Instant; diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs similarity index 98% rename from crates/render/tests/placed_pdf.rs rename to crates/render/tests/it/placed_pdf.rs index 234e43ba7..118453067 100644 --- a/crates/render/tests/placed_pdf.rs +++ b/crates/render/tests/it/placed_pdf.rs @@ -1,4 +1,4 @@ -//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! Placed PDFs are drawn through craft-pdf's patched Hayro crates. Calculator functions whose `idiv` //! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh //! shadings with points far off the page, ten thousand overlapping triangles or forty thousand //! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is @@ -9,14 +9,14 @@ use std::sync::Arc; use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::Duration; +use craft_pdf::interpret::Function; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::{Object, ObjectIdentifier}; use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, PdfCrop, Shape, SpreadRef}; use designcraft_geom::{Affine, Rect, shapes}; use designcraft_render::{Rendered, Renderer, pdf_crop_box, pdf_page_size}; -use hayro::hayro_interpret::Function; -use hayro::hayro_syntax::Pdf; -use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; const WHITE: [u8; 4] = [255, 255, 255, 255]; const RED: [u8; 4] = [255, 0, 0, 255]; diff --git a/crates/render/tests/stroke_alignment.rs b/crates/render/tests/it/stroke_alignment.rs similarity index 100% rename from crates/render/tests/stroke_alignment.rs rename to crates/render/tests/it/stroke_alignment.rs diff --git a/crates/render/tests/text_frame_bottom.rs b/crates/render/tests/it/text_frame_bottom.rs similarity index 100% rename from crates/render/tests/text_frame_bottom.rs rename to crates/render/tests/it/text_frame_bottom.rs diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/it/text_stroke_behind.rs similarity index 100% rename from crates/render/tests/text_stroke_behind.rs rename to crates/render/tests/it/text_stroke_behind.rs diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 4b52e2ee5..ff65e7ce7 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -76,6 +76,7 @@ pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: x_edges, y_edges, exclude: &[], + exclude_guides: &[], copying: false, lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())], angle: None, @@ -160,6 +161,7 @@ fn line_hit(cx: &ToolContext, spread: SpreadRef, point: Point, len: Option) x_edges: [true, true, true], y_edges: [true, true, true], exclude: &[], + exclude_guides: &[], copying: false, lengths: [len, None], angle: None, diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a92f1c132..f0fd68019 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -140,6 +140,9 @@ pub struct SnapView { pub smart_dimensions: bool, pub smart_spacing: bool, pub zone_px: f64, + /// View › Grids & Guides › Lock Guides: ruler guides can't be selected or moved. + #[serde(default)] + pub guides_locked: bool, } impl SnapView { @@ -153,6 +156,7 @@ impl SnapView { smart_dimensions: true, smart_spacing: true, zone_px: 4.0, + guides_locked: false, }; pub const OFF: Self = Self { snap_to_guides: false, @@ -164,6 +168,7 @@ impl SnapView { smart_dimensions: false, smart_spacing: false, zone_px: 4.0, + guides_locked: false, }; /// Guides, the document grid, or smart guides are on. @@ -186,6 +191,9 @@ pub enum Gesture { Create, Rotate, Point, + /// A ruler guide being dragged: one line on one axis, with no size or spacing to match and + /// no pull from the baseline grid. + Guide, } pub struct SnapRequest<'a> { @@ -197,6 +205,8 @@ pub struct SnapRequest<'a> { /// Top, center, bottom. pub y_edges: [bool; 3], pub exclude: &'a [ItemId], + /// Ruler guides that are not targets: the ones being dragged. + pub exclude_guides: &'a [designcraft_doc::GuideSel], pub copying: bool, /// Proposed width and height for the dimensions pass. None when that length is not being set. pub lengths: [Option; 2], diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 891f1d4ab..866003102 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -2,7 +2,8 @@ //! //! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), -//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and +//! empty-canvas drags make a marquee (it selects the ruler guides it crosses when it touches no +//! object), double-clicking a text frame switches to the Type tool and //! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one). //! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]). //! @@ -179,6 +180,42 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The ruler guides a marquee `r` (canvas) crosses that can be selected, as `guide.select` +/// takes them: none while guides are hidden or locked. +fn guides_crossing(cx: &ToolContext, r: Rect) -> Vec { + if !cx.snap.show_guides || cx.snap.guides_locked { + return vec![]; + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + let Some(sp) = cx.doc.spread(slot.spread) else { continue }; + let pasteboard = slot.xf.inverse().transform_rect_bbox(cx.layout.pasteboard); + for (pi, pg) in sp.pages.iter().enumerate() { + for (gi, g) in pg.guides.iter().enumerate() { + if !g.editable_in(cx.doc) { + continue; + } + // The line as the canvas draws it: a spread guide crosses the pasteboard (a + // vertical one the spread's height and a little), a page guide its page. + let span = match (g.spread, g.orientation) { + (false, _) => pg.bounds(), + (true, designcraft_doc::Orientation::Horizontal) => pasteboard, + (true, designcraft_doc::Orientation::Vertical) => sp.bounds().inflate(0.0, 36.0), + }; + let line = match g.orientation { + designcraft_doc::Orientation::Horizontal => Rect::new(span.x0, g.position, span.x1, g.position), + designcraft_doc::Orientation::Vertical => Rect::new(g.position, span.y0, g.position, span.y1), + }; + let b = slot.xf.transform_rect_bbox(line); + if b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0 { + out.push(json!({"spread": slot.spread, "page": pi, "index": gi})); + } + } + } + } + out +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -892,6 +929,7 @@ impl Tool for SelectionTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: ev.mods.alt && !content, lengths: [None, None], angle: None, @@ -965,6 +1003,7 @@ impl Tool for SelectionTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)], angle: None, @@ -1017,6 +1056,7 @@ impl Tool for SelectionTool { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(resulting), @@ -1055,6 +1095,7 @@ impl Tool for SelectionTool { x_edges: [true, true, true], y_edges: [true, true, true], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1112,7 +1153,12 @@ impl Tool for SelectionTool { } } } - vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] + let guides = if ids.is_empty() { guides_crossing(cx, r) } else { vec![] }; + if guides.is_empty() { + vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] + } else { + vec![Action::Exec("guide.select".into(), json!({"guides": guides, "add": ev.mods.shift}))] + } } Drag::None | Drag::Place { .. } => vec![], } @@ -1133,7 +1179,20 @@ impl Tool for SelectionTool { } let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; if cx.selection.items.is_empty() { - return vec![]; + // Selected ruler guides: the arrows nudge them, Delete deletes them. + if cx.selection.guides.is_empty() { + return vec![]; + } + let nudge = |dx: f64, dy: f64| vec![Action::Exec("guide.moveBy".into(), json!({"dx": dx, "dy": dy}))]; + return match key { + ToolKey::Left => nudge(-inc, 0.0), + ToolKey::Right => nudge(inc, 0.0), + ToolKey::Up => nudge(0.0, -inc), + ToolKey::Down => nudge(0.0, inc), + ToolKey::Delete | ToolKey::Backspace => vec![Action::Exec("edit.clear".into(), json!({}))], + ToolKey::Escape => vec![Action::Exec("guide.deselect".into(), json!({}))], + _ => vec![], + }; } match key { ToolKey::Left => mv(-inc, 0.0), diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 0cbc9aadd..11da46178 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -31,6 +31,7 @@ pub fn snap_point(cx: &ToolContext, sr: SpreadRef, p: Point) -> (Point, Vec, axis: Axis) -> Option= 0 while the line is inside the page /// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the /// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not -/// turn this off. Only a page the rect crosses on x contributes. +/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide +/// ([`Gesture::Guide`]) doesn't snap to it. fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { - if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides { + if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) { return None; } let baseline = &cx.doc.settings.baseline_grid; @@ -1000,7 +1002,8 @@ fn push_parent_lengths(out: &mut Vec, cx: &ToolContext, req: &SnapRequest<' /// /// A page guide attracts when the moving rect overlaps that page on the other axis. A spread /// guide uses the spread bounds. Locked guides are included, and so are the guides a page shows -/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it. +/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it, and not +/// when the request excludes it (the guides being dragged). fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !cx.snap.snap_to_guides || !cx.snap.show_guides { return None; @@ -1010,10 +1013,15 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> let flags = axis_flags(req, axis); let (moving_lo, moving_hi) = perp_ends(req.rect, axis); let mut best: Option = None; - for page in &sp.pages { + for (pi, page) in sp.pages.iter().enumerate() { let page_bounds = page.bounds(); - // The page's own guides, then those it shows from its parent page. - for guide in page.guides.iter().cloned().chain(cx.doc.parent_guides(page)) { + // The page's own guides, less the excluded ones, then those it shows from its parent page. + let own = page + .guides + .iter() + .enumerate() + .filter(|&(gi, _)| !req.exclude_guides.contains(&designcraft_doc::GuideSel { spread: req.spread, page: pi, index: gi })); + for guide in own.map(|(_, g)| g.clone()).chain(cx.doc.parent_guides(page)) { if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) { continue; } @@ -1427,6 +1435,7 @@ mod tests { x_edges: [false, false, false], y_edges: [true, true, true], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1443,6 +1452,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1475,6 +1485,7 @@ mod tests { x_edges: [false, false, false], y_edges: [true, true, true], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1504,6 +1515,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1515,6 +1527,28 @@ mod tests { assert!(hit.guides.is_empty()); } + /// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102. + /// Nor does the baseline grid pull it. + #[test] + fn excluded_guide_is_not_a_target() { + let far = Rect::new(400.0, 400.0, 420.0, 420.0); + let (doc, _, _) = doc_with_items(far, far, 104.0); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let line = Rect::new(102.0, 100.0, 102.0, 300.0); + let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }]; + let hit = snap(&cx, move_req(line, &[])); + assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta); + let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) }); + assert_eq!(hit.delta.x, 0.0); + // A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets. + let flat = Rect::new(100.0, 86.0, 300.0, 86.0); + assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6); + assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0); + } + #[test] fn parent_centered_stroke_attracts_at_the_outer_edge() { let mut doc = Document::new(&NewDocument::default()); @@ -1542,6 +1576,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1569,6 +1604,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1599,6 +1635,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -1630,6 +1667,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[id], + exclude_guides: &[], copying: true, lengths: [None, None], angle: None, @@ -1666,6 +1704,7 @@ mod tests { x_edges: [true, false, false], y_edges: [false, false, false], exclude: &[original], + exclude_guides: &[], copying: true, lengths: [None, None], angle: None, @@ -1793,6 +1832,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(197.0), None], angle: None, @@ -1836,6 +1876,7 @@ mod tests { x_edges: [false, false, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(82.0), None], angle: None, @@ -1871,6 +1912,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[moving], + exclude_guides: &[], copying: false, lengths: [None, None], angle: None, @@ -2009,6 +2051,7 @@ mod tests { x_edges: [true, true, true], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [Some(38.0), None], angle: None, @@ -2041,6 +2084,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(28.0), @@ -2085,6 +2129,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(28.0), @@ -2157,6 +2202,7 @@ mod tests { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &[], + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(angle), diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 258bcd62b..561ad0c86 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -100,6 +100,7 @@ impl Tool for XformTool { x_edges: [false, false, false], y_edges: [false, false, false], exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [None, None], angle: Some(resulting), @@ -151,6 +152,7 @@ impl Tool for XformTool { x_edges, y_edges, exclude: &exclude, + exclude_guides: &[], copying: false, lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)], angle: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..318f82388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2,7 +2,7 @@ //! selection, text ports and threads, the text caret, rulers, and pointer/keyboard input. use designcraft_compose as compose; -use designcraft_doc::{Content, Document, Item, SpreadRef}; +use designcraft_doc::{Content, Document, GuideSel, Item, SpreadRef}; use designcraft_engine::doc::Selection; use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2}; use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey}; @@ -531,21 +531,41 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside); } if !preview { - draw_guides(app, &painter, &xf, &doc, &layout, &t); - if let Some(g) = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))) + let drag = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))); + let press = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_PRESS))); + let moving = drag.as_ref().zip(press).and_then(|(g, press)| moving_guides(app, &xf, rect, g, press)); + draw_guides(app, &painter, &xf, &doc, &layout, &t, moving.as_ref()); + if let Some(g) = drag && let Some(p) = g.at { let col = Color32::from_rgb(74, 227, 255); + let spot = guide_spot(app, &xf, &g); + let line = spot.as_ref().map_or(p, |s| s.screen); let (a, b) = match g.orientation { - designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), - designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), + designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, line.y), pos2(rect.max.x, line.y)), + designcraft_doc::Orientation::Vertical => (pos2(line.x, rect.min.y), pos2(line.x, rect.max.y)), }; + // What caught it, as for a moved object. + for mark in spot.iter().flat_map(|s| &s.marks) { + if let Overlay::Guide { a, b } = mark { + painter.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, t.smart_guide)); + } + } // A new guide from the rulers with the Page tool is a liquid guide: dashed. if g.from.is_none() && app.session.tool_id() == "page" { dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); } else { painter.line_segment([a, b], Stroke::new(1.0, col)); } + // Where it is, beside the pointer (#1282). + if let Some(text) = guide_readout(app, &xf, &g) { + let galley = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), t.text); + let at = readout_at(p, galley.size(), rect); + let bg = Rect::from_min_size(at, galley.size()).expand(3.0); + painter.rect_filled(bg, 2.0, t.panel); + painter.rect_stroke(bg, 2.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + painter.galley(at, galley, t.text); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -603,6 +623,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; + // A column guide that a press would drag, or one being dragged: a resize cursor across it. + let dragging = ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let column = dragging.or_else(|| { + let menu_open = context_menu_open(ui.ctx()); + (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) + .then(|| column_guide_at(app, &xf, p).map(|h| h.0)) + .flatten() + }); + let c = match column { + Some(r) if spread_x_across(app, r) => Cursor::ResizeH, + Some(_) => Cursor::ResizeV, + None => c, + }; let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); @@ -950,8 +983,11 @@ fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Re boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b)) } -fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { +/// `moving`: guides being dragged, drawn where they would be dropped instead of where they are. +fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens, moving: Option<&MovingGuides>) { let s = &doc.settings; + let selection: std::collections::HashSet = + app.session.active().map(|st| st.selection.guides.iter().copied().collect()).unwrap_or_default(); for slot in &layout.slots { let Some(sp) = doc.spread(slot.spread) else { continue }; // Everything below is in spread coordinates, placed through the slot (which may be turned). @@ -1007,8 +1043,21 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document } } } - // The page's own guides, then those it shows from its parent page. - for g in p.guides.iter().cloned().chain(doc.parent_guides(p)).filter(|g| g.visible_in(doc)) { + // The page's own guides, then those it shows from its parent page. Selected guides are + // drawn thicker, in their layer's color. + let own = p.guides.len(); + for (gi, mut g) in p.guides.iter().cloned().chain(doc.parent_guides(p)).enumerate().filter(|(_, g)| g.visible_in(doc)) { + let key = GuideSel { spread: slot.spread, page: pi, index: gi }; + let selected = gi < own && selection.contains(&key); + if gi < own + && let Some(m) = moving + && m.guides.contains(&key) + { + g.position += match g.orientation { + designcraft_doc::Orientation::Vertical => m.dx, + designcraft_doc::Orientation::Horizontal => m.dy, + }; + } // Spread guides cross the pasteboard; page guides their page. let span = if g.spread { pasteboard } else { pr }; let (a, bb) = match g.orientation { @@ -1018,7 +1067,12 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document (Point::new(g.position, span.y0), Point::new(g.position, span.y1)) } }; - let stroke = Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)); + let stroke = if selected { + let color = g.layer.and_then(|l| doc.layer(l)).map_or(Color32::from_rgb(79, 153, 255), |l| c32(l.color)); + Stroke::new(2.0, color) + } else { + Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)) + }; if g.liquid { // Liquid guides are dashed. painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0)); @@ -1529,6 +1583,21 @@ fn field_focused_id(ctx: &egui::Context) -> egui::Id { pub fn begin_frame(ctx: &egui::Context) { let (id, focused) = (field_focused_id(ctx), ctx.text_edit_focused()); ctx.data_mut(|d| d.insert_temp(id, focused)); + #[cfg(not(target_arch = "wasm32"))] + { + let now = ctx.input(|i| i.modifiers); + // The modifiers last frame ended with are the ones this frame's events began with. + ctx.data_mut(|d| { + let (_, end) = d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id()).unwrap_or((now, now)); + d.insert_temp(modifiers_id(), (end, now)); + }); + } +} + +/// (held when this frame's events began, held after them). +#[cfg(not(target_arch = "wasm32"))] +fn modifiers_id() -> egui::Id { + egui::Id::new("canvas-frame-modifiers") } /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). @@ -2013,6 +2082,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, /// coordinates: the ruler origin (one per page for a page origin) moved by zero point `zp`. fn ruler_pieces(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zp: [f64; 2]) -> Vec<(f64, f64, Point)> { let Some(slot) = current_slot(app, layout).and_then(|i| layout.slots.get(i)) else { return Vec::new() }; + ruler_pieces_of(doc, slot, zp) +} + +/// [`ruler_pieces`] for the spread in `slot`. +fn ruler_pieces_of(doc: &Document, slot: &SpreadSlot, zp: [f64; 2]) -> Vec<(f64, f64, Point)> { let x = |v: f64, y: f64| if v.is_finite() { slot.to_canvas(Point::new(v, y)).x } else { v }; doc.ruler_pieces(slot.spread) .into_iter() @@ -2347,6 +2421,11 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live // Distribute while resizing). Middle-drag pans too, with any tool; tools only // see the primary button. + // A column guide drag keeps the pointer until the button is up, whatever Space or a menu does + // meanwhile. + if ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).is_some() && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); let hand = space && !ui.input(|i| i.modifiers.command); if !tool_drag && ((hand && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { @@ -2375,9 +2454,16 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if !space && !menu_open && xf.rot == 0 && zero_point_drag(app, ui, resp, rect, &xf) { return; } + // Hidden or locked guides can't stay selected. + if (!app.ui.guides || app.ui.guides_locked) && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) { + let _ = app.run("guide.deselect", json!({})); + } if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } + if !space && !menu_open && xf.rot == 0 && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let m = ui.input(|i| mods(i, space)); let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); @@ -2475,7 +2561,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // text must not delete the character before it). let composing_at_start = app.session.tool_composing(); let evs = ui.input(|i| i.events.clone()); + // Native only: egui-winit sends a ⌘+V chord as a paste without its key press, so the modifiers + // held at the paste tell Paste in Place from Paste. The web also gets the key press, and the + // shortcut table has already run the command. A chord's modifiers may be released in the same + // frame, hence the modifiers held at each event. + #[cfg(not(target_arch = "wasm32"))] + let mut held = + ui.data(|d| d.get_temp::<(egui::Modifiers, egui::Modifiers)>(modifiers_id())).map_or_else(|| ui.input(|i| i.modifiers), |(start, _)| start); for e in evs { + #[cfg(not(target_arch = "wasm32"))] + let chord = { + if let egui::Event::ModifiersChanged(m) = &e { + held = *m; + } + if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, held) } else { None } + }; let composing = composing_at_start || app.session.tool_composing(); match e { egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi), @@ -2497,6 +2597,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; let _ = app.run(id, json!({})); } + // Paste in Place's chord (⌘⌥⇧V unless rebound) arrives as a paste too. + #[cfg(not(target_arch = "wasm32"))] + egui::Event::Paste(_) if chord.is_some() => { + if let Some(id) = chord + && crate::menus::enabled(app, id) + { + crate::menus::activate(app, id, &serde_json::Value::Null); + } + } // Without a native menu bar the copy and paste keys arrive only as these events. egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => { let (id, params) = match e { @@ -2645,33 +2754,47 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc for l in &ft.lines { let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; let font = egui::FontId::proportional(size); - for g in &l.glyphs { - if g.len == 0 { - continue; - } - let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' '); - let mark = match ch { - ' ' => "·", - '\t' => "»", - '\u{2028}' => "¬", - '\u{A0}' => "°", - _ => continue, - }; - let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline)); - let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + for (x, mark, centered) in hidden_marks(&story.text, l) { + let p = xf.to_screen(m * Point::new(x, l.baseline)); + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; painter.text(p, align, mark, font.clone(), col); } - if l.last_in_para { - let end = l.range.end; - let mark = if end >= story.text.len() { "#" } else { "¶" }; - let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline)); - painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col); - } } } } } +/// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and +/// whether the mark is centered on x (spaces) or starts at it. +fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { + let mut out = Vec::new(); + for g in &l.glyphs { + if g.len == 0 { + continue; + } + let Some(ch) = text.get(g.byte..).and_then(|t| t.chars().next()) else { continue }; + let mark = match ch { + ' ' => "·", + '\t' => "»", + '\u{2028}' => "¬", + '\u{A0}' => "°", + _ => continue, + }; + if ch == ' ' { + out.push((g.x + g.adv / 2.0, mark, true)); + } else { + out.push((g.x, mark, false)); + } + } + if l.last_in_para { + // The spaces trimmed off the line's end sit past `end_x`; the paragraph mark follows them. + let end = l.glyphs.iter().filter(|g| g.len > 0 && !g.visible && g.x >= l.end_x - 1e-9).map(|g| g.x + g.adv).fold(l.end_x, f64::max); + let mark = if l.range.end >= text.len() { "#" } else { "¶" }; + out.push((end + 1.0, mark, false)); + } + out +} + /// View › Flattener Preview: red over every object that involves transparency (opacity, blend /// modes, effects, knockout) — what flattening or export rasterising affects. fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { @@ -2839,6 +2962,184 @@ struct GuideDrag { from: Option<(designcraft_doc::SpreadRef, usize, usize)>, /// Pointer position (screen). at: Option, + /// Modifiers held at the pointer's last move: Shift snaps to the ruler's ticks, Command + /// suspends snapping. + mods: egui::Modifiers, +} + +const GUIDE_PRESS: &str = "canvas_guide_press"; + +/// A press on an existing guide: where it was (screen) and whether Shift was down. Released +/// where it was pressed, it is a click, which selects the guide. +#[derive(Clone, Copy, Debug)] +struct GuidePress { + origin: Pos2, + shift: bool, +} + +/// How far (screen pixels) the pointer may move between press and release for a click. +const GUIDE_CLICK_SLOP: f32 = 3.0; + +/// Where a dragged guide is, as the rulers measure it (#1282): `X: …` for a vertical guide, `Y: …` +/// for a horizontal one, from the zero point (and the page origin, when rulers restart per page) +/// in the document's ruler units. `None` with no document or rulers. +fn guide_readout(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option { + let (label, value, unit) = guide_measure(app, xf, g)?; + Some(format!("{label}: {}", designcraft_geom::units::format_measure(value, unit))) +} + +/// Where a pointer readout of `size` goes for a pointer at `p`: below right of it, on the other +/// side where that would leave the canvas `area` (near its right or bottom edge), and kept inside +/// it (with the label's 3-point frame) however small the canvas is. +fn readout_at(p: Pos2, size: egui::Vec2, area: Rect) -> Pos2 { + const GAP: f32 = 12.0; + const PAD: f32 = 3.0; + let x = if p.x + GAP + size.x + PAD > area.max.x { p.x - GAP - size.x } else { p.x + GAP }; + let y = if p.y + GAP + size.y + PAD > area.max.y { p.y - GAP - size.y } else { p.y + GAP }; + let clamp = |v: f32, lo: f32, hi: f32| if hi < lo { lo } else { v.clamp(lo, hi) }; + pos2(clamp(x, area.min.x + PAD, area.max.x - PAD - size.x), clamp(y, area.min.y + PAD, area.max.y - PAD - size.y)) +} + +/// [`guide_readout`]'s parts: the axis label, the value (points) and its unit. +fn guide_measure(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option<(&'static str, f64, designcraft_geom::Unit)> { + let p = g.at?; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + // Measured where the guide is dropped (`guide_drag`): its snapped spot, on the spread under + // the pointer, as the rulers measure that spread, not the one in the middle of the view. + let (c, slot) = match guide_spot(app, xf, g) { + Some(spot) => (layout.to_canvas(spot.spread, spot.sp), layout.slot(spot.spread)?), + None => (xf.to_canvas(p), current_slot(app, &layout).and_then(|i| layout.slots.get(i))?), + }; + // An existing guide moves only on its own spread (`guide_drag`): over another, dropping it + // leaves it where it was, so there is no place to show. + if g.from.is_some_and(|(r, _, _)| r != slot.spread) { + return None; + } + let pieces = ruler_pieces_of(&st.doc, slot, st.doc.zero_point()); + let first = pieces.first()?; + let (label, value, unit) = match g.orientation { + designcraft_doc::Orientation::Vertical => { + // The horizontal ruler restarts at each page: the piece under the guide measures it. + let o = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).unwrap_or(first).2; + ("X", c.x - o.x, st.doc.settings.horizontal_units) + } + designcraft_doc::Orientation::Horizontal => ("Y", c.y - first.2.y, st.doc.settings.vertical_units), + }; + value.is_finite().then_some((label, value, unit)) +} + +/// Where a dragged ruler guide is (#1221). +struct GuideSpot { + /// The spread under the pointer. + spread: SpreadRef, + /// The pointer in that spread's coordinates. + pointer: Point, + /// [`Self::pointer`] with the guide's axis snapped. + sp: Point, + /// [`Self::sp`] on screen, where the preview line goes. + screen: Pos2, + /// Alignment marks (canvas coordinates) when an object or page edge or center caught it. + marks: Vec, +} + +/// Where the guide of drag `g` is: its axis (x for a vertical guide, y for a horizontal one) +/// snapped as a moved object's is, to ruler guides, margins, columns, bleed, the document grid and +/// smart-guide targets (not the baseline grid, which would catch a horizontal guide almost +/// anywhere). Shift snaps to the ruler's ticks instead and Command suspends snapping. The guides +/// being dragged are not targets. An existing guide over another spread, where it +/// can't be dropped, stays at the pointer. The preview, the readout and the drop all read it. +/// `None` with no spread under the pointer. +fn guide_spot(app: &DesignApp, xf: &Xf, g: &GuideDrag) -> Option { + let p = g.at?; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, pointer) = layout.spread_at(xf.to_canvas(p))?; + let spot = |sp: Point, marks: Vec| GuideSpot { spread: r, pointer, sp, screen: xf.to_screen(layout.to_canvas(r, sp)), marks }; + if g.mods.command || g.from.is_some_and(|(from, _, _)| from != r) { + return Some(GuideSpot { spread: r, pointer, sp: pointer, screen: p, marks: Vec::new() }); + } + let vertical = matches!(g.orientation, designcraft_doc::Orientation::Vertical); + let place = |v: f64| if vertical { Point::new(v, pointer.y) } else { Point::new(pointer.x, v) }; + if g.mods.shift { + let slot = layout.slot(r)?; + let at = ruler_tick_of(&st.doc, slot, g.orientation, xf.zoom, pointer).map_or(pointer, place); + return Some(spot(at, Vec::new())); + } + let spread = st.doc.spread(r)?; + // Not targets: the dragged guide, and the others of a selected group, which move with it. + let exclude: Vec = match g.from { + Some((sr, pi, gi)) if drags_group(app, GuideSel { spread: sr, page: pi, index: gi }) => st.selection.guides.to_vec(), + Some((sr, pi, gi)) => vec![GuideSel { spread: sr, page: pi, index: gi }], + None => Vec::new(), + }; + // How far the guide reaches across its axis: its page, or the whole spread. + let page = match g.from { + Some((_, pi, gi)) => spread.pages.get(pi).filter(|pg| pg.guides.get(gi).is_some_and(|guide| !guide.spread)), + None => spread.pages.iter().find(|pg| pg.bounds().contains(pointer)), + }; + let reach = page.map_or_else(|| spread.bounds(), |pg| pg.bounds()); + let (at, rect, x_edges, y_edges) = if vertical { + (pointer.x, DRect::new(pointer.x, reach.y0, pointer.x, reach.y1), [true; 3], [false; 3]) + } else { + (pointer.y, DRect::new(reach.x0, pointer.y, reach.x1, pointer.y), [false; 3], [true; 3]) + }; + let cx = designcraft_tools::ToolContext { + doc: &st.doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: xf.zoom.max(1e-6), + layer: st.active_layer, + snap: app.ui.snap_view(), + unit: app.ui.units, + stroke_in_front: false, + }; + let s = designcraft_tools::snap::snap( + &cx, + designcraft_tools::SnapRequest { + spread: r, + gesture: designcraft_tools::Gesture::Guide, + rect, + x_edges, + y_edges, + exclude: &[], + exclude_guides: &exclude, + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer, + }, + ); + let snapped = at + if vertical { s.delta.x } else { s.delta.y }; + Some(if snapped.is_finite() { spot(place(snapped), s.guides) } else { spot(pointer, Vec::new()) }) +} + +/// The nearest ruler tick to spread point `sp` along a guide of `orientation`'s axis, in spread +/// coordinates: the horizontal ruler's (the piece under the point) where that axis runs across the +/// screen, else the vertical ruler's. `None` when the ruler has no ticks there. +fn ruler_tick_of(doc: &Document, slot: &SpreadSlot, orientation: designcraft_doc::Orientation, zoom: f64, sp: Point) -> Option { + let vertical = matches!(orientation, designcraft_doc::Orientation::Vertical); + // A quarter-turned spread runs its x axis down the screen. + let across = vertical == slot.rotation.is_multiple_of(2); + let c = slot.to_canvas(sp); + let pieces = ruler_pieces_of(doc, slot, doc.zero_point()); + let step = |unit: designcraft_geom::Unit| { + let (major, sub) = unit.ruler_ticks(zoom); + let step = major / f64::from(sub.max(1)); + (step.is_finite() && step > 0.0).then_some(step) + }; + let tick = |v: f64, o: f64, step: f64| o + ((v - o) / step).round() * step; + let c = if across { + let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= c.x && c.x < *to).or(pieces.first())?; + Point::new(tick(c.x, o.x, step(doc.settings.horizontal_units)?), c.y) + } else { + let o = pieces.first()?.2; + Point::new(c.x, tick(c.y, o.y, step(doc.settings.vertical_units)?)) + }; + let s = slot.to_spread(c); + Some(if vertical { s.x } else { s.y }).filter(|v| v.is_finite()) } /// The guide under a screen point (within 3 px), as (spread, page, index, orientation). @@ -2870,8 +3171,10 @@ fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::Sprea None } -/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools, not over an -/// object) to move it, drop it back on a ruler to delete it. Returns true while it owns the pointer. +/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools) to move it, drop +/// it back on a ruler to delete it. A press near an unlocked guide takes it even over an object: +/// the guide is selected and the object is not. Returns true from the press to the release, so +/// the tools never see that press. fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { let id = egui::Id::new(GUIDE_DRAG); let (pressed, released, origin, latest) = @@ -2890,29 +3193,27 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec None }; if let Some(orientation) = orientation { - drag = Some(GuideDrag { orientation, from: None, at: Some(o) }); + drag = Some(GuideDrag { orientation, from: None, at: Some(o), mods: egui::Modifiers::NONE }); } } else if rect.contains(o) && matches!(app.session.tool_id(), "selection" | "directSelection") && let Some((r, pi, gi, orientation)) = guide_at(app, xf, o) { - // Objects take precedence over the guides behind them. - let over_item = app.session.active().is_some_and(|st| { - let layout = CanvasLayout::new(&st.doc, st.editing_parents); - layout.spread_at(xf.to_canvas(o)).is_some_and(|(sr, p)| match sr { - designcraft_doc::SpreadRef::Doc(si) => st.doc.hit_item(si, p, 3.0 / xf.zoom).is_some(), - _ => false, - }) - }); - if !over_item { - drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) }); + let shift = ui.input(|i| i.modifiers.shift); + // A press on a guide selects it (Shift: on release, as a click adds or removes it). + let selected = app.session.active().is_some_and(|st| st.selection.guides.contains(&GuideSel { spread: r, page: pi, index: gi })); + if !shift && !selected { + let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi})); } + ui.data_mut(|d| d.insert_temp(egui::Id::new(GUIDE_PRESS), GuidePress { origin: o, shift })); + drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE }); } } let Some(mut g) = drag else { return false }; if let Some(p) = latest { g.at = Some(p); } + g.mods = ui.input(|i| i.modifiers); if !released { ui.ctx().set_cursor_icon(match g.orientation { designcraft_doc::Orientation::Horizontal => egui::CursorIcon::ResizeVertical, @@ -2922,14 +3223,29 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec return true; } ui.data_mut(|d| d.remove::(id)); + let press: Option = ui.data_mut(|d| { + let press = d.get_temp(egui::Id::new(GUIDE_PRESS)); + d.remove::(egui::Id::new(GUIDE_PRESS)); + press + }); let Some(p) = g.at else { return true }; + if let (Some((r, pi, gi)), Some(press)) = (g.from, press) + && (p - press.origin).length() <= GUIDE_CLICK_SLOP + { + // A click: the guide is selected (at the press); Shift adds it or removes it. + if press.shift { + let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi, "toggle": true})); + } + return true; + } + // Dragging one of several selected guides moves (or deletes) them all. + let group = g.from.is_some_and(|(spread, page, index)| drags_group(app, GuideSel { spread, page, index })); let on_canvas = rect.contains(p); - let target = app.session.active().and_then(|st| { - let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let (r, sp) = layout.spread_at(xf.to_canvas(p))?; - let spread = st.doc.spread(r)?; - let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(sp)); - Some((r, sp, on_page)) + // Placed where the preview showed it (snapped); the page under the pointer owns a new guide. + let target = guide_spot(app, xf, &g).and_then(|spot| { + let spread = app.session.active()?.doc.spread(spot.spread)?; + let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(spot.pointer)); + Some((spot.spread, spot.sp, on_page)) }); let pos_of = |sp: Point| match g.orientation { designcraft_doc::Orientation::Horizontal => sp.y, @@ -2941,9 +3257,17 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec }; match (g.from, on_canvas, target) { // Dropped back on a ruler: delete. + (Some(_), false, _) if group => { + let _ = app.run("guide.delete", json!({})); + } (Some((r, pi, gi)), false, _) => { let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi})); } + (Some((r, _, _)), true, Some((tr, _, _))) if tr == r && group => { + if let Some((dx, dy)) = press.and_then(|press| guide_drag_delta(app, xf, &g, press.origin)) { + let _ = app.run("guide.moveBy", json!({"dx": dx, "dy": dy})); + } + } (Some((r, pi, gi)), true, Some((tr, sp, _))) if tr == r => { let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } @@ -2962,6 +3286,272 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +/// Whether dragging guide `from` takes the other selected guides along: it is one of several +/// selected. +fn drags_group(app: &DesignApp, from: GuideSel) -> bool { + app.session.active().is_some_and(|st| st.selection.guides.len() > 1 && st.selection.guides.contains(&from)) +} + +/// How far a drag of the existing guide `g.from`, pressed at `origin` (screen), moves guides, in +/// spread coordinates: the dragged guide goes to its [`guide_spot`], the other selected guides as +/// far along their own axis as it moved. `None` while the pointer is not over the guide's own +/// spread, where a drop leaves it in place. +fn guide_drag_delta(app: &DesignApp, xf: &Xf, g: &GuideDrag, origin: Pos2) -> Option<(f64, f64)> { + let (r, pi, gi) = g.from?; + let spot = guide_spot(app, xf, g)?; + if spot.spread != r { + return None; + } + let sp = spot.sp; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let start = layout.slot(r)?.to_spread(xf.to_canvas(origin)); + let at = st.doc.spread(r)?.pages.get(pi)?.guides.get(gi)?.position; + let (dx, dy) = match g.orientation { + designcraft_doc::Orientation::Vertical => (sp.x - at, sp.y - start.y), + designcraft_doc::Orientation::Horizontal => (sp.x - start.x, sp.y - at), + }; + (dx.is_finite() && dy.is_finite()).then_some((dx, dy)) +} + +/// Guides being dragged on the canvas and how far they have moved (spread coordinates). +struct MovingGuides { + guides: std::collections::HashSet, + dx: f64, + dy: f64, +} + +/// What a drag of an existing guide moves, for the preview: the dragged guide, or every selected +/// guide when it is one of several selected. `None` before the drag leaves the click distance, +/// over a ruler (a drop there deletes) and off the guide's spread. +fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: GuidePress) -> Option { + let (spread, page, index) = g.from?; + let p = g.at?; + if !rect.contains(p) || (p - press.origin).length() <= GUIDE_CLICK_SLOP { + return None; + } + let (dx, dy) = guide_drag_delta(app, xf, g, press.origin)?; + let from = GuideSel { spread, page, index }; + let guides = if drags_group(app, from) { app.session.active()?.selection.guides.iter().copied().collect() } else { [from].into_iter().collect() }; + Some(MovingGuides { guides, dx, dy }) +} + +/// An object is under screen point `p` where the Selection tool would pick it (a document +/// spread's, or a parent spread's while editing parents): it takes the press before the guides +/// behind it. +fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { + let Some(st) = app.session.active() else { return false }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cx = designcraft_tools::ToolContext { + doc: &st.doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: xf.zoom.max(1e-6), + layer: st.active_layer, + snap: app.ui.snap_view(), + unit: app.ui.units, + stroke_in_front: false, + }; + cx.hit(xf.to_canvas(p)).is_some() +} + +const COLUMN_DRAG: &str = "canvas_column_drag"; + +/// A column guide being dragged with a Selection tool: its gutter follows the pointer. +#[derive(Clone, Debug)] +struct ColumnDrag { + /// The document the drag's interaction belongs to. + doc: u64, + spread: SpreadRef, + page: usize, + gutter: usize, + /// The pointer's spread x minus the gutter's left edge at the press: the gutter keeps that + /// distance from the pointer. + grab: f64, + /// The left edge last previewed. + last: Option, +} + +/// The interior column guide under screen point `p` (within 3 px) that a press there would drag, +/// as (spread, page, gutter, the gutter's left edge in spread x): a Selection tool, guides shown +/// (not in Preview or Presentation), column guides unlocked, and no object or ruler guide that +/// takes the press there. +fn column_guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(SpreadRef, usize, usize, f64)> { + let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); + if !app.ui.guides || preview || !matches!(app.session.tool_id(), "selection" | "directSelection") { + return None; + } + let st = app.session.active()?; + if st.doc.settings.column_guides_locked { + return None; + } + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, sp) = layout.spread_at(xf.to_canvas(p))?; + let tol = 3.0 / xf.zoom.max(1e-9); + let hit = st.doc.spread(r)?.pages.iter().enumerate().find_map(|(pi, pg)| { + let m = pg.margin_rect(); + if sp.y < m.y0 - tol || sp.y > m.y1 + tol { + return None; + } + pg.column_rects().windows(2).enumerate().find_map(|(gi, w)| match w { + // Columns that cannot move (gutters wider than the margin box) offer no guide. + [a, b] + if ((sp.x - a.x1).abs() <= tol || (sp.x - b.x0).abs() <= tol) + && pg.columns.with_gutter_moved(m.width(), gi, a.x1 - m.x0).is_some() => + { + Some((r, pi, gi, a.x1)) + } + _ => None, + }) + })?; + // A ruler guide there takes the press first (see `guide_drag`). + let ruler_guide = !app.ui.guides_locked && guide_at(app, xf, p).is_some(); + (!ruler_guide && !over_item(app, xf, p)).then_some(hit) +} + +/// Whether spread `r`'s x axis runs across the screen (the spread view is not turned a quarter). +fn spread_x_across(app: &DesignApp, r: SpreadRef) -> bool { + app.session.active().is_none_or(|st| { + let [a, b, ..] = CanvasLayout::new(&st.doc, st.editing_parents).xf(r).as_coeffs(); + a.abs() >= b.abs() + }) +} + +/// Canvas x `x` moved to the nearest tick mark of the horizontal ruler (its finest increment). +fn ruler_tick_x(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zoom: f64, x: f64) -> Option { + let pieces = ruler_pieces(app, doc, layout, doc.zero_point()); + let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= x && x < *to).or(pieces.first())?; + let (major, sub) = doc.settings.horizontal_units.ruler_ticks(zoom); + let step = major / f64::from(sub.max(1)); + (step.is_finite() && step > 0.0).then(|| o.x + ((x - o.x) / step).round() * step).filter(|v| v.is_finite()) +} + +/// Where a dragged gutter's left edge goes for spread x `want`: Shift snaps it to the ruler's tick +/// marks; otherwise its edges snap to the document grid, guides and objects as those snaps are +/// set. Measured against the document as it was when the drag began. +fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f64) -> f64 { + let Some(st) = app.session.active() else { return want }; + let doc = st.interaction.as_ref().map_or_else(|| st.doc.clone(), |i| i.doc.clone()); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let Some(slot) = layout.slots.iter().find(|s| s.spread == g.spread) else { return want }; + let Some(pg) = doc.spread(g.spread).and_then(|sp| sp.pages.get(g.page)) else { return want }; + let m = pg.margin_rect(); + if shift { + if !spread_x_across(app, g.spread) { + return want; + } + let c = slot.to_canvas(Point::new(want, m.y0)); + return ruler_tick_x(app, &doc, &layout, zoom, c.x).map_or(want, |x| slot.to_spread(Point::new(x, c.y)).x); + } + let cols = pg.column_rects(); + let width = match (cols.get(g.gutter), cols.get(g.gutter + 1)) { + (Some(a), Some(b)) => (b.x0 - a.x1).max(0.0), + _ => 0.0, + }; + let cx = designcraft_tools::ToolContext { + doc: &doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: zoom.max(1e-6), + layer: st.active_layer, + // A line has no size or spacing to match. + snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, + unit: app.ui.units, + stroke_in_front: false, + }; + let rect = DRect::new(want, m.y0, want + width, m.y1); + let s = designcraft_tools::snap::snap( + &cx, + designcraft_tools::SnapRequest { + spread: g.spread, + gesture: designcraft_tools::Gesture::Move, + rect, + x_edges: [true, false, true], + y_edges: [false; 3], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(want, m.center().y), + }, + ); + let x = want + s.delta.x; + if x.is_finite() { x } else { want } +} + +/// Column guides: with a Selection tool, drag an interior column guide (not over an object) to +/// move its gutter, both guides together; Escape puts it back. One undo step per drag. Returns +/// true while it owns the pointer. +fn column_guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { + let id = egui::Id::new((COLUMN_DRAG, app.pane)); + let (pressed, released, origin, latest, shift, escape) = ui.input(|i| { + ( + i.pointer.primary_pressed(), + // A release this frame, or one missed while something else had the pointer. + i.pointer.primary_released() || !i.pointer.primary_down(), + i.pointer.press_origin(), + i.pointer.latest_pos(), + i.modifiers.shift, + i.key_pressed(egui::Key::Escape), + ) + }); + let mut drag: Option = ui.data(|d| d.get_temp(id)); + if drag.is_none() + && pressed + && resp.hovered() + && let Some(o) = origin.filter(|o| rect.contains(*o)) + && let Some((spread, page, gutter, left)) = column_guide_at(app, xf, o) + // Another gesture or a dialog's preview owns the document until it ends. + && let Some((doc, slot_x)) = app + .session + .active() + .filter(|st| st.interaction.is_none()) + .map(|st| (st.uid, CanvasLayout::new(&st.doc, st.editing_parents).to_spread(spread, xf.to_canvas(o)).x)) + && app.session.begin_interaction("Move Column Guide").is_ok() + { + drag = Some(ColumnDrag { doc, spread, page, gutter, grab: slot_x - left, last: None }); + } + let Some(mut g) = drag else { return false }; + // Another document became active: the drag ends in its own, as it began. + if app.session.active().is_none_or(|st| st.uid != g.doc) { + app.session.cancel_interaction_of(g.doc); + ui.data_mut(|d| d.remove::(id)); + return false; + } + if escape { + app.session.cancel_interaction(); + ui.data_mut(|d| d.remove::(id)); + return true; + } + if let Some(p) = latest + && let Some(x) = app.session.active().map(|st| CanvasLayout::new(&st.doc, st.editing_parents).to_spread(g.spread, xf.to_canvas(p)).x) + && x.is_finite() + { + let want = snap_gutter(app, &g, x - g.grab, shift, xf.zoom); + if g.last != Some(want) && (g.last.is_some() || (p - origin.unwrap_or(p)).length() > 0.0) { + let params = json!({"spread": g.spread, "page": g.page, "gutter": g.gutter, "position": want}); + if let Err(e) = app.session.preview("layout.moveColumnGuide", params) { + app.status(e.to_string()); + } + g.last = Some(want); + } + } + if released { + ui.data_mut(|d| d.remove::(id)); + if let Err(e) = app.session.commit_interaction() { + app.status(e.to_string()); + } + app.after_engine(); + return true; + } + ui.data_mut(|d| d.insert_temp(id, g)); + true +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] @@ -2991,6 +3581,10 @@ fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { #[path = "parent_canvas_tests.rs"] mod parent_canvas_tests; +#[cfg(test)] +#[path = "guide_selection_tests.rs"] +mod guide_selection_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -3193,6 +3787,138 @@ mod tests { assert!(!g[1].liquid); } + /// Dragging a column guide with the Selection tool moves its gutter (one undo step); with + /// column guides locked the same drag leaves the columns alone. + #[test] + fn the_selection_tool_drags_a_column_guide() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + // Two columns across the 540 pt margin box: the gutter's left edge is at x = 300. + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + let mut h = harness(app); + let page = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].clone(); + let screen = |h: &Harness<'_, DesignApp>, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let layout = CanvasLayout::new(&app.session.active().unwrap().doc, false); + let slot = layout.slots.iter().find(|s| s.spread == SpreadRef::Doc(0)).unwrap(); + (xf.to_screen(slot.xf * Point::new(x, 400.0)), xf.zoom as f32) + }; + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + assert_eq!(page(&h).columns.positions, None, "column guides are locked in a new document"); + h.hover_at(at); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + + h.state_mut().run("view.lockColumnGuides", json!({"on": false})).unwrap(); + h.run_steps(2); + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + let pg = page(&h); + let cols = pg.column_rects(); + assert!((cols[0].x1 - 372.0).abs() < 1.0 && (cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9, "{cols:?}"); + assert!(pg.local_margins, "the dragged page's columns are its own"); + // Over the moved guide: a resize cursor. + h.hover_at(screen(&h, 372.0).0); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "one undo step"); + + // Escape during the drag puts the gutter back. + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + assert!(page(&h).columns.positions.is_some(), "the drag previews"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, None); + assert!(!page(&h).local_margins); + + // Space pressed during the drag and the button released under it: the drag ends with the + // release and the gutter stops following the pointer. + let key = |pressed| egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + h.event(key(true)); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.step(); + h.event(key(false)); + h.run_steps(2); + let moved = page(&h).columns.positions; + assert!(moved.is_some()); + h.hover_at(at + vec2(120.0, 0.0)); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, moved, "the gutter stays where the drag ended"); + assert!(h.state().session.active().unwrap().interaction.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "the drag was one undo step"); + } + + /// A column guide is not picked where the screen mode hides guides, where an object on the + /// parent being edited lies over it, or where its columns cannot move. + #[test] + fn column_guides_are_picked_only_where_a_drag_can_move_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + app.run("view.lockColumnGuides", json!({"on": false})).unwrap(); + let mut h = harness(app); + let hit = |h: &Harness<'_, DesignApp>, r: SpreadRef, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = layout.slots.iter().find(|s| s.spread == r).unwrap(); + column_guide_at(app, &xf, xf.to_screen(slot.xf * Point::new(x, 400.0))) + }; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_some()); + h.state_mut().ui.screen_mode = ScreenMode::Preview; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_none(), "Preview hides the guides"); + h.state_mut().ui.screen_mode = ScreenMode::Normal; + + // Editing parent A: its right page (the one page 0 follows) and a frame over its gutter. + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let parent = SpreadRef::Parent(0); + let x = h.state().session.active().unwrap().doc.parents[0].pages[1].column_rects()[0].x1; + assert!(hit(&h, parent, x).is_some()); + h.state_mut() + .run("frame.create", json!({"spread": {"kind": "parent", "index": 0}, "rect": [x - 20.0, 300.0, x + 20.0, 500.0], "content": "text"})) + .unwrap(); + h.run_steps(2); + assert!(hit(&h, parent, x).is_none(), "the parent's frame takes the press"); + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.run_steps(2); + + // Gutters wider than the margin box: the even split cannot be moved. + h.state_mut().run("layout.marginsAndColumns", json!({"pages": [0], "columns": 2, "gutter": 1000})).unwrap(); + h.run_steps(2); + let x = h.state().session.active().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1; + assert!(hit(&h, SpreadRef::Doc(0), x).is_none()); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); @@ -3590,6 +4316,39 @@ mod tests { assert!((it.bounds().center() - centre).hypot() < 0.01, "{:?} vs {centre:?}", it.bounds()); } + #[test] + fn the_paste_in_place_shortcut_pastes_in_place_and_plain_paste_offsets() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let original = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + app.run("edit.copy", json!({})).unwrap(); + let clip = app.object_clip_text.clone().expect("the copy's placeholder text"); + let mut h = harness(app); + let paste = |h: &mut Harness<'static, DesignApp>, modifiers: egui::Modifiers| { + // As egui-winit delivers ⌃⌥⇧V: the chord's paste and the V release, no V press; here the + // modifiers are released within the same frame. + let release = egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers }; + let none = egui::Event::ModifiersChanged(egui::Modifiers::NONE); + h.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), egui::Event::Paste(clip.clone()), release, none]); + h.run_steps(2); + let st = h.state().session.doc().unwrap(); + let pasted = *st.selection.items.first().expect("the copy is selected"); + assert_ne!(pasted.0, id); + st.doc.item(pasted).unwrap().bounds() + }; + let linux = egui::Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; + assert_eq!(paste(&mut h, linux), original, "Paste in Place"); + let moved = paste(&mut h, egui::Modifiers::COMMAND); + assert!((moved.x0 - original.x0 - 12.0).abs() < 1e-9 && (moved.y0 - original.y0 - 12.0).abs() < 1e-9, "{moved:?}"); + // The modifiers went down in an earlier frame than the paste. + h.event_modifiers(egui::Event::Paste(clip.clone()), linux); + h.run_steps(2); + let st = h.state().session.doc().unwrap(); + let pasted = *st.selection.items.first().expect("the copy is selected"); + assert_eq!(st.doc.item(pasted).unwrap().bounds(), original); + } + #[test] fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() { let services = crate::Services { clipboard_text: Some(Box::new(|| Some(ART.to_string()))), ..Default::default() }; @@ -4033,6 +4792,115 @@ mod tests { assert_eq!(doc.settings.zero_point, [0.0, 0.0], "the zero point stays"); } + /// A guide being dragged out of a ruler says where it is, as the rulers measure it (#1282): + /// from the zero point, in the ruler units, and the label follows the pointer while it moves. + #[test] + fn a_dragged_guide_shows_where_it_is() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + let dest = rect.min + vec2(160.0, 110.0); + let (xf, at) = { + let app = h.state(); + let xf = Xf::new(rect, app.view().unwrap()); + let st = app.session.active().unwrap(); + (xf, CanvasLayout::new(&st.doc, false).spread_at(xf.to_canvas(dest)).unwrap().1) + }; + let units = h.state().session.doc().unwrap().doc.settings.horizontal_units; + for (orientation, label, want) in + [(designcraft_doc::Orientation::Vertical, "X", at.x - 100.0), (designcraft_doc::Orientation::Horizontal, "Y", at.y - 50.0)] + { + let g = GuideDrag { orientation, from: None, at: Some(dest), mods: egui::Modifiers::NONE }; + let (l, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); + assert_eq!(l, label); + assert!((v - want).abs() < 1e-6, "{label}: {v} vs {want}"); + let text = guide_readout(h.state(), &xf, &g).unwrap(); + assert_eq!(text, format!("{label}: {}", designcraft_geom::units::format_measure(v, units))); + } + // While a guide is actually being dragged out of the top ruler, the readout is there. + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + h.hover_at(ruler); + h.step(); + primary(&mut h, ruler, true); + h.event(egui::Event::PointerMoved(dest)); + h.run_steps(2); + let g = h.ctx.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).expect("dragging"); + assert_eq!(g.orientation, designcraft_doc::Orientation::Horizontal); + assert!(guide_readout(h.state(), &xf, &g).is_some_and(|t| t.starts_with("Y: "))); + primary(&mut h, dest, false); + } + + /// Over a spread other than the one in the middle of the view, the readout measures that + /// spread, the one the guide is dropped on: it reads the position the guide gets. + #[test] + fn the_guide_readout_measures_the_spread_under_the_pointer() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5})).unwrap(); + app.run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + let h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + let xf = Xf::new(rect, h.state().view().unwrap()); + let st = h.state().session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let current = current_slot(h.state(), &layout).unwrap(); + // A point inside the last spread, well away from the one in view. + let last = layout.slots.last().unwrap(); + assert_ne!(layout.slots.iter().position(|s| s.spread == last.spread), Some(current), "the last spread isn't the one in view"); + let inside = Point::new(last.bounds.center().x, last.bounds.y0 + (last.bounds.y1 - last.bounds.y0) * 0.3); + let (r, sp) = layout.spread_at(inside).unwrap(); + assert_eq!(r, last.spread); + let g = GuideDrag { + orientation: designcraft_doc::Orientation::Horizontal, + from: None, + at: Some(xf.to_screen(inside)), + mods: egui::Modifiers::NONE, + }; + let (label, v, _) = guide_measure(h.state(), &xf, &g).unwrap(); + assert_eq!(label, "Y"); + // The guide would be stored at `sp.y` on that spread, which its ruler reads from the zero point. + // (To screen and back in f32: a few hundred-thousandths of a point.) + assert!((v - (sp.y - 50.0)).abs() < 1e-3, "{v} vs {}", sp.y - 50.0); + // An existing guide of another spread can't be dropped there (it only moves on its own + // spread): no readout over this one, a readout over its own. + let first = layout.slots[current].spread; + let moving = GuideDrag { + orientation: designcraft_doc::Orientation::Horizontal, + from: Some((first, 0, 0)), + at: Some(xf.to_screen(inside)), + mods: egui::Modifiers::NONE, + }; + assert!(guide_measure(h.state(), &xf, &moving).is_none(), "an existing guide over another spread"); + let own = layout.slots[current].bounds; + let over_own = Point::new(own.center().x, own.y0 + (own.y1 - own.y0) * 0.3); + let moving_here = GuideDrag { at: Some(xf.to_screen(over_own)), ..moving }; + assert!(guide_measure(h.state(), &xf, &moving_here).is_some(), "over its own spread"); + } + + /// The guide readout stays on the canvas: below right of the pointer in the open, flipped + /// left or up near the right or bottom edge, and inside the canvas in every corner. + #[test] + fn the_guide_readout_stays_inside_the_canvas() { + let area = Rect::from_min_max(pos2(50.0, 40.0), pos2(850.0, 640.0)); + let size = vec2(80.0, 14.0); + let inside = |at: Pos2| { + let r = Rect::from_min_size(at, size).expand(3.0); + r.min.x >= area.min.x - 1e-3 && r.min.y >= area.min.y - 1e-3 && r.max.x <= area.max.x + 1e-3 && r.max.y <= area.max.y + 1e-3 + }; + assert_eq!(readout_at(pos2(400.0, 300.0), size, area), pos2(412.0, 312.0), "in the open: below right"); + let right = readout_at(pos2(845.0, 300.0), size, area); + assert!(right.x + size.x < 845.0 && inside(right), "near the right edge: left of the pointer, {right:?}"); + let bottom = readout_at(pos2(400.0, 636.0), size, area); + assert!(bottom.y + size.y < 636.0 && inside(bottom), "near the bottom: above it, {bottom:?}"); + for p in [area.min, pos2(area.max.x, area.min.y), area.max, pos2(area.min.x, area.max.y), pos2(-500.0, 2000.0)] { + assert!(inside(readout_at(p, size, area)), "{p:?}"); + } + // A canvas narrower than the label: pinned to its top left, not past it. + let tiny = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 10.0)); + assert_eq!(readout_at(pos2(20.0, 5.0), size, tiny), pos2(3.0, 3.0)); + } + #[test] fn rulers_and_xy_fields_measure_from_the_zero_point_on_every_spread() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -4777,6 +5645,29 @@ mod tests { assert_eq!(size, 18.0, "{key:?} applies the typed size"); } } + + #[test] + fn trailing_spaces_show_dots_before_the_paragraph_mark() { + let text = "ab \ncd "; + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let rect = DRect::new(0.0, 0.0, 300.0, 100.0); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), rect, lid, text, Default::default()).unwrap(); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let lines: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).collect(); + assert_eq!(lines.len(), 2); + for (l, end_mark) in lines.iter().zip(["¶", "#"]) { + let marks = hidden_marks(text, l); + let dots: Vec = marks.iter().filter(|m| m.1 == "·").map(|m| m.0).collect(); + assert_eq!(dots.len(), 2, "a dot for each trailing space: {marks:?}"); + let &(para_x, mark, _) = marks.last().unwrap(); + assert_eq!(mark, end_mark); + // The dots sit past the last letter, and the mark comes after both spaces. + let space = l.glyphs.iter().rev().find(|g| g.len > 0).unwrap(); + assert!(dots.iter().all(|&x| x > l.end_x && x < para_x), "{dots:?} then {mark} at {para_x}"); + assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); + } + } } #[cfg(test)] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..e853e6793 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1155,7 +1155,7 @@ mod tests { /// The application bar's search field (German interface). fn search_node<'h>(h: &'h egui_kittest::Harness<'static, test_window::Window>) -> egui_kittest::Node<'h> { - h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some("Suchen")) + h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.placeholder() == Some(crate::i18n::tr("de", "Search"))) } /// A document with two frames, the Selection tool, German menus, and the search field clicked. @@ -1187,12 +1187,12 @@ mod tests { #[test] fn the_search_field_finds_and_runs_menu_commands() { let mut h = searching(); - h.event(egui::Event::Text("gruppier".into())); + let group = crate::i18n::tr("de", "Group"); + h.event(egui::Event::Text(group.to_lowercase())); h.run_steps(4); - assert!(row(&h, "Gruppieren").is_some(), "the German label is listed"); - assert!(row(&h, "Gruppierung aufheben").is_some()); + assert!(row(&h, group).is_some(), "the German label is listed"); // Nothing is selected, so Group is off and Return doesn't run it. - assert!(row(&h, "Gruppieren").is_some_and(|n| n.accesskit_node().is_disabled())); + assert!(row(&h, group).is_some_and(|n| n.accesskit_node().is_disabled())); h.key_press(egui::Key::Enter); h.run_steps(4); assert_eq!(items(&h), (2, 0), "a disabled command doesn't run"); @@ -1205,7 +1205,7 @@ mod tests { h.key_press(egui::Key::Enter); h.run_steps(4); assert_eq!(items(&h).0, 1, "the two frames are grouped"); - assert!(row(&h, "Gruppieren").is_none(), "the matches closed"); + assert!(row(&h, group).is_none(), "the matches closed"); assert!(!h.ctx.memory(|m| m.has_focus(super::search_id())), "the field gave the keyboard back"); } @@ -1227,11 +1227,11 @@ mod tests { assert_eq!(items(&h), (2, 2), "nothing was deleted or deselected"); h.event(egui::Event::Text("zz-no-such-command".into())); h.run_steps(4); - assert!(h.query_by_label("Keine passenden Befehle").is_some()); + assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_some()); // Escape closes the matches and clears the field without touching the document. h.key_press(egui::Key::Escape); h.run_steps(4); - assert!(h.query_by_label("Keine passenden Befehle").is_none()); + assert!(h.query_by_label(crate::i18n::tr("de", "No matching commands")).is_none()); assert!(!h.ctx.text_edit_focused()); assert_eq!(h.state().app.session.tool_id(), "rectangleFrame"); assert_eq!(items(&h), (2, 2)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e6886d71..46a00172d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if !msg.is_empty() { ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60))); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { - let _ = app.run("window.resetShortcuts", json!({})); - } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { + let _ = app.run("window.resetShortcuts", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked() + && let Some(msg) = crate::shortcut_import::pick_and_import(app) + { + d.fields.insert("message".into(), json!(msg)); + } + }); } /// File › Print, with the printers the system knows. @@ -8971,7 +8978,7 @@ mod tests { assert_eq!(app.ui.language, "de"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let heading = visible_label(&labels, screen, "Sprache der Oberfläche"); + let heading = visible_label(&labels, screen, crate::i18n::tr("de", "Interface Language")); let combo = visible_label(&labels, screen, "Deutsch"); // The names stay endonyms in the German interface. diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs new file mode 100644 index 000000000..d69026712 --- /dev/null +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -0,0 +1,327 @@ +//! Ruler guides selected like objects (#1282): click, Shift-click, marquee, drag, keys, and the +//! X/Y fields. + +use egui::{Event, Modifiers, PointerButton}; +use egui_kittest::Harness; + +use super::*; + +fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +/// A page with vertical guides at x 200 and 300 and a horizontal one at y 400. +fn with_guides() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for (orientation, position) in [("vertical", 200.0), ("vertical", 300.0), ("horizontal", 400.0)] { + app.run("guide.add", json!({"orientation": orientation, "position": position})).unwrap(); + } + harness(app) +} + +/// Screen position of spread 0's point (x, y). +fn screen_of(h: &Harness<'_, DesignApp>, x: f64, y: f64) -> Pos2 { + let app = h.state(); + let st = app.session.active().unwrap(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + xf.to_screen(CanvasLayout::new(&st.doc, st.editing_parents).xf(SpreadRef::Doc(0)) * Point::new(x, y)) +} + +fn press(h: &mut Harness<'_, DesignApp>, pos: Pos2, pressed: bool, mods: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods }); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, DesignApp>, pos: Pos2, mods: Modifiers) { + h.event(Event::ModifiersChanged(mods)); + h.hover_at(pos); + h.step(); + press(h, pos, true, mods); + press(h, pos, false, mods); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + press(h, a, true, Modifiers::NONE); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + press(h, b, false, Modifiers::NONE); + h.run_steps(2); +} + +fn selected(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().selection.guides.iter().map(|g| g.index).collect() +} + +fn positions(h: &Harness<'_, DesignApp>) -> Vec { + h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.iter().map(|g| g.position).collect() +} + +#[test] +fn clicking_guides_selects_them_and_shift_click_adds_or_removes() { + let mut h = with_guides(); + let id = h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + let at = screen_of(&h, 200.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [0]); + assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the object is deselected"); + assert_eq!(positions(&h), [200.0, 300.0, 400.0], "a click doesn't move the guide"); + let at = screen_of(&h, 100.0, 400.0); + click(&mut h, at, Modifiers::SHIFT); + assert_eq!(selected(&h), [0, 2]); + let at = screen_of(&h, 200.0, 650.0); + click(&mut h, at, Modifiers::SHIFT); + assert_eq!(selected(&h), [2]); + // A click on empty canvas, or on an object, clears the guide selection. + let at = screen_of(&h, 550.0, 650.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); + let at = screen_of(&h, 300.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [1]); + let at = screen_of(&h, 450.0, 150.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); + // With View › Lock Guides on, guides can't be selected, and selected ones are deselected. + h.state_mut().run("guide.selectAll", json!({})).unwrap(); + h.state_mut().ui.guides_locked = true; + h.run_steps(2); + assert!(selected(&h).is_empty()); + let at = screen_of(&h, 200.0, 600.0); + click(&mut h, at, Modifiers::NONE); + assert!(selected(&h).is_empty()); +} + +#[test] +fn dragging_a_selected_guide_moves_all_selected_guides_as_one_step() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap(); + let undo = h.state().session.doc().unwrap().history.undo.len(); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0)); + drag(&mut h, a, b); + let p = positions(&h); + assert!((p[0] - 250.0).abs() < 1.0 && p[1] == 300.0 && (p[2] - 420.0).abs() < 1.0, "{p:?}"); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(selected(&h), [0, 2]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&h), [200.0, 300.0, 400.0]); + // Dragging an unselected guide selects it and moves only it. + let (a, b) = (screen_of(&h, 300.0, 600.0), screen_of(&h, 350.0, 600.0)); + drag(&mut h, a, b); + assert_eq!(selected(&h), [1]); + let p = positions(&h); + assert!(p[0] == 200.0 && (p[1] - 350.0).abs() < 1.0 && p[2] == 400.0, "{p:?}"); +} + +#[test] +fn a_marquee_selects_guides_only_when_it_touches_no_object() { + let mut h = with_guides(); + // Across both vertical guides, below the horizontal one, on empty page. + let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0)); + drag(&mut h, a, b); + assert_eq!(selected(&h), [0, 1]); + let id = h.state_mut().run("frame.create", json!({"rect": [220, 520, 260, 540]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let (a, b) = (screen_of(&h, 150.0, 500.0), screen_of(&h, 350.0, 550.0)); + drag(&mut h, a, b); + assert!(selected(&h).is_empty()); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); +} + +#[test] +fn keys_nudge_and_delete_selected_guides() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 1}, {"page": 0, "index": 2}]})).unwrap(); + let inc = h.state().session.doc().unwrap().doc.settings.keyboard_increment; + let vi = h.state().view_info(); + let key = |h: &mut Harness<'_, DesignApp>, k: ToolKey, shift: bool| { + let m = Mods { shift, alt: false, cmd: false, ctrl: false, space: false }; + assert!(h.state_mut().session.tool_key(k, m, vi).unwrap()); + }; + key(&mut h, ToolKey::Right, false); + key(&mut h, ToolKey::Down, true); + assert_eq!(positions(&h), [200.0, 300.0 + inc, 400.0 + 10.0 * inc]); + key(&mut h, ToolKey::Delete, false); + assert_eq!(positions(&h), [200.0]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&h).len(), 3); + assert_eq!(selected(&h), [1, 2]); + key(&mut h, ToolKey::Escape, false); + assert!(selected(&h).is_empty()); +} + +#[test] +fn the_guide_field_measures_from_the_zero_point() { + let mut h = with_guides(); + h.state_mut().run("view.zeroPoint", json!({"at": [100, 50]})).unwrap(); + h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap(); + let f = crate::panels::guide_field(h.state()).unwrap(); + assert_eq!((f.row(), f.value), (0, 100.0)); + h.state_mut().run("guide.move", f.move_to(36.0)).unwrap(); + assert_eq!(positions(&h)[0], 136.0); + h.state_mut().run("guide.select", json!({"page": 0, "index": 2})).unwrap(); + let f = crate::panels::guide_field(h.state()).unwrap(); + assert_eq!((f.row(), f.value), (1, 350.0)); + // Several guides: no field value. + h.state_mut().run("guide.selectAll", json!({})).unwrap(); + assert!(crate::panels::guide_field(h.state()).is_none()); +} + +#[test] +fn select_all_guides_is_in_the_edit_menu_on_the_spread_in_view() { + let mut h = with_guides(); + assert_eq!(crate::menus::default_shortcut("guide.selectAll"), Some("Cmd+Alt+G")); + crate::menus::activate(h.state_mut(), "guide.selectAll", &serde_json::Value::Null); + assert_eq!(selected(&h), [0, 1, 2]); + h.state_mut().ui.guides = false; + assert!(!crate::menus::menu_enabled(h.state(), "guide.selectAll")); +} + +#[test] +fn a_guide_over_a_frame_takes_the_click() { + let mut h = with_guides(); + h.state_mut().run("guide.deselect", json!({})).unwrap(); + // The vertical guide at x 200 crosses the frame. + let id = h.state_mut().run("frame.create", json!({"rect": [150, 500, 260, 560]})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let bounds = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].items[0].bounds(); + let before = bounds(&h); + let at = screen_of(&h, 200.0, 530.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(selected(&h), [0]); + assert!(h.state().session.doc().unwrap().selection.items.is_empty(), "the frame is not selected"); + // Dragging it there moves the guide, not the frame. + let (a, b) = (screen_of(&h, 200.0, 530.0), screen_of(&h, 230.0, 530.0)); + drag(&mut h, a, b); + assert!((positions(&h)[0] - 230.0).abs() < 1.0, "{:?}", positions(&h)); + assert_eq!(bounds(&h), before); + assert!(h.state().session.doc().unwrap().selection.items.is_empty()); + // Away from the guide, a click inside the frame selects it. + let at = screen_of(&h, 160.0, 530.0); + click(&mut h, at, Modifiers::NONE); + assert_eq!(h.state().session.doc().unwrap().selection.items, [designcraft_doc::ItemId(id)]); + assert!(selected(&h).is_empty()); +} + +#[test] +fn a_guide_dragged_out_of_a_ruler_is_the_selection() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"page": 0, "index": 0})).unwrap(); + let rect = h.state().canvas_rect.unwrap(); + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + let dest = screen_of(&h, 250.0, 500.0); + drag(&mut h, ruler, dest); + let p = positions(&h); + assert!(p.len() == 4 && (p[3] - 500.0).abs() < 1.0, "{p:?}"); + assert_eq!(selected(&h), [3]); +} + +#[test] +fn dragging_one_of_several_selected_guides_previews_them_all() { + let mut h = with_guides(); + h.state_mut().run("guide.select", json!({"guides": [{"page": 0, "index": 0}, {"page": 0, "index": 2}]})).unwrap(); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, 250.0, 620.0)); + let g = + GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE }; + let press = GuidePress { origin: a, shift: false }; + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let m = moving_guides(app, &xf, app.canvas_rect.unwrap(), &g, press).expect("moving"); + assert!((m.dx - 50.0).abs() < 1.0 && (m.dy - 20.0).abs() < 1.0, "{} {}", m.dx, m.dy); + let mut moved: Vec = m.guides.iter().map(|g| g.index).collect(); + moved.sort_unstable(); + assert_eq!(moved, [0, 2]); + // The drop moves them by the same amounts. + let (dx, dy) = guide_drag_delta(app, &xf, &g, a).unwrap(); + assert_eq!((dx, dy), (m.dx, m.dy)); +} + +/// Spread points per screen pixel at the harness's view. +fn pt_per_px(h: &Harness<'_, DesignApp>) -> f64 { + let app = h.state(); + 1.0 / Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).zoom +} + +/// A letter page with two columns: the left column's right side. +fn with_columns() -> (Harness<'static, DesignApp>, f64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"preset": "Letter", "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + app.run("guide.add", json!({"orientation": "vertical", "position": 200.0})).unwrap(); + let side = app.session.doc().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1; + (harness(app), side) +} + +/// A dragged guide snaps to a column guide as a moved object does (#1221). +#[test] +fn a_dragged_guide_snaps_to_a_column_guide() { + let (mut h, side) = with_columns(); + let near = side + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + drag(&mut h, a, b); + assert_eq!(positions(&h), [side]); +} + +/// Snap to Guides and Smart Guides off: the guide goes where the pointer is. +#[test] +fn with_snapping_off_a_dragged_guide_stays_at_the_pointer() { + let (mut h, side) = with_columns(); + h.state_mut().ui.snap_to_guides = false; + h.state_mut().ui.smart_guides = false; + let near = side + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + drag(&mut h, a, b); + let p = positions(&h); + assert!((p[0] - near).abs() < 0.1 && p[0] != side, "{p:?} vs {near}"); +} + +/// A frame's edge catches a dragged guide, and the preview shows the alignment mark. +#[test] +fn a_dragged_guide_snaps_to_a_frame_edge_with_a_smart_guide() { + let mut h = with_guides(); + h.state_mut().run("frame.create", json!({"rect": [400, 100, 500, 200]})).unwrap(); + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let near = 400.0 + 1.5 * pt_per_px(&h); + let (a, b) = (screen_of(&h, 200.0, 600.0), screen_of(&h, near, 600.0)); + let g = + GuideDrag { orientation: designcraft_doc::Orientation::Vertical, from: Some((SpreadRef::Doc(0), 0, 0)), at: Some(b), mods: Modifiers::NONE }; + { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let spot = guide_spot(app, &xf, &g).unwrap(); + assert_eq!(spot.sp.x, 400.0); + assert!(spot.marks.iter().any(|m| matches!(m, Overlay::Guide { .. })), "{:?}", spot.marks); + // Command suspends snapping. + let spot = guide_spot(app, &xf, &GuideDrag { mods: Modifiers::COMMAND, ..g.clone() }).unwrap(); + assert!((spot.sp.x - near).abs() < 0.1 && spot.marks.is_empty()); + } + drag(&mut h, a, b); + assert_eq!(positions(&h), [400.0, 300.0, 400.0]); +} + +/// A new guide dragged out of a ruler snaps too: here to the horizontal guide at y 400. +#[test] +fn a_guide_dragged_out_of_a_ruler_snaps() { + let mut h = with_guides(); + let rect = h.state().canvas_rect.unwrap(); + let ruler = pos2(rect.min.x + 200.0, rect.min.y - RULER / 2.0); + let dest = screen_of(&h, 250.0, 400.0 + 1.5 * pt_per_px(&h)); + drag(&mut h, ruler, dest); + assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b5e9d89ad..905b7d862 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -139,7 +139,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Pages on Top", ["Seiten oben", "Pages en haut", "Páginas en la parte superior", "ページを上に表示", "页面在上"]), ( "Parents on Top", - ["Übergeordnete Seiten oben", "Parents en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"], + ["Übergeordnete Seiten oben", "Gabarits en haut", "Páginas principales en la parte superior", "親ページを上に表示", "父页面在上"], ), ("Resize:", ["Größe ändern:", "Redimensionner :", "Cambiar tamaño:", "サイズ変更:", "调整大小:"]), ("Extra Small", ["Sehr klein", "Très petite", "Extra pequeño", "極小", "特小"]), @@ -150,8 +150,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Jumbo", ["Riesig", "Géante", "Gigante", "最大", "巨大"]), ("Proportional", ["Proportional", "Proportionnel", "Proporcional", "比例", "按比例"]), ("Pages Fixed", ["Seiten fixiert", "Pages fixes", "Páginas fijas", "ページを固定", "页面固定"]), - ("Parents Fixed", ["Übergeordnete fixiert", "Parents fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), - ("Parents", ["Übergeordnete Seiten", "Parents", "Páginas principales", "親ページ", "父页面"]), + ("Parents Fixed", ["Übergeordnete fixiert", "Gabarits fixes", "Páginas principales fijas", "親ページを固定", "父页面固定"]), + ("Parents", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), ( "Bulleted & Numbered Lists", [ @@ -322,6 +322,11 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), ("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]), + ( + "Select All Guides", + ["Alle Hilfslinien auswählen", "Sélectionner tous les repères", "Seleccionar todas las guías", "すべてのガイドを選択", "选择所有参考线"], + ), + ("Ruler Guide", ["Hilfslinie", "Repère de règle", "Guía de regla", "定規ガイド", "标尺参考线"]), ("Find/Change…", ["Suchen/Ersetzen …", "Rechercher/Remplacer…", "Buscar/Cambiar…", "検索と置換…", "查找/更改…"]), ("Check Spelling…", ["Rechtschreibprüfung …", "Vérifier l'orthographe…", "Revisar ortografía…", "スペルチェック…", "拼写检查…"]), ("Preferences…", ["Voreinstellungen …", "Préférences…", "Preferencias…", "環境設定…", "首选项…"]), @@ -496,7 +501,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("ICF Top", ["ICF oben", "Haut ICF", "Parte superior ICF", "平均字面の上", "平均字面上"]), ("ICF Bottom", ["ICF unten", "Bas ICF", "Parte inferior ICF", "平均字面の下", "平均字面下"]), - ("Gyoudori:", ["Gyoudori:", "Gyoudori:", "Gyoudori:", "行取り:", "行取:"]), + ("Gyoudori:", ["Gyoudori:", "Gyoudori :", "Gyoudori:", "行取り:", "行取:"]), ( "Use Paragraph Gyoudori", ["Absatz-Gyoudori verwenden", "Utiliser le gyoudori de paragraphe", "Usar gyoudori de párrafo", "段落行取りを使用", "使用段落行取"], @@ -733,7 +738,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), - ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), + ("Essentials Classic", ["Essentials Classic", "Les indispensables (classique)", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]), ("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]), ( @@ -872,7 +877,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), - ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ("Parent Pages", ["Übergeordnete Seiten", "Gabarits", "Páginas principales", "親ページ", "父页面"]), ( "Apply Parent to Pages", [ @@ -1388,12 +1393,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Apply None (/)", ["Apply None (/)", "Appliquer Aucun (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), ( "Default Fill and Stroke", - ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ["Default Fill and Stroke", "Fond et contour par défaut", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], ), - ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Permuter fond et contour", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), ( "Toggle Fill and Stroke", - ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ["Toggle Fill and Stroke", "Basculer fond/contour", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], ), ("Reverse the gradient", ["Reverse the gradient", "Inverser le dégradé", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Source du style", "Style source", "Style source", "样式源"]), @@ -2061,7 +2066,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Show/Hide Document Grid", - ["Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "Show/Hide Document Grid", "显示/隐藏文档网格"], + [ + "Show/Hide Document Grid", + "Afficher/Masquer la grille du document", + "Show/Hide Document Grid", + "Show/Hide Document Grid", + "显示/隐藏文档网格", + ], ), ( "Show/Hide Frame Edges", @@ -2713,7 +2724,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Select an object or text to color its fill or stroke.", [ "Select an object or text to color its fill or stroke.", - "Select an object or text to color its fill or stroke.", + "Sélectionnez un objet ou du texte pour colorer son fond ou son contour.", "Select an object or text to color its fill or stroke.", "Select an object or text to color its fill or stroke.", "选择一个对象或文本以设置其填色或描边颜色。", @@ -2922,7 +2933,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cap Height", ["Cap Height", "Hauteur de capitale", "Cap Height", "Cap Height", "大写字母高度"]), ("Leading", ["Leading", "Interligne", "Leading", "Leading", "行距"]), ("x Height", ["x Height", "Hauteur d'x", "x Height", "x Height", "x 字高"]), - ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]), + ("Em Box Height", ["Em Box Height", "Hauteur du cadratin", "Em Box Height", "Em Box Height", "全角字框高度"]), ("Fixed", ["Fixed", "Fixe", "Fixed", "Fixed", "固定"]), ("Never (continuous)", ["Never (continuous)", "Jamais (continu)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]), ("Page", ["Page", "Page", "Page", "Page", "页面"]), @@ -3160,10 +3171,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Graphic…", ["Graphic…", "Graphique…", "Graphic…", "Graphic…", "图形…"]), ("Home", ["Home", "Accueil", "Home", "Home", "主页"]), ("Join:", ["Join:", "Sommet :", "Join:", "Join:", "连接:"]), - ("Language", ["Language", "Langue", "Language", "Language", "语言"]), + ("Language", ["Sprache", "Langue", "Idioma", "言語", "语言"]), ("Last Spread", ["Last Spread", "Dernière planche", "Last Spread", "Last Spread", "最后一个跨页"]), ("Load DTD…", ["Load DTD…", "Charger la DTD…", "Load DTD…", "Load DTD…", "载入 DTD…"]), ("Location:", ["Location:", "Emplacement :", "Location:", "Location:", "位置:"]), + ( + "Lock Column Guides", + ["Spaltenhilfslinien sperren", "Verrouiller les repères de colonne", "Bloquear guías de columna", "列ガイドをロック", "锁定栏参考线"], + ), ("Lock Guides", ["Lock Guides", "Verrouiller les repères", "Lock Guides", "Lock Guides", "锁定参考线"]), ("Miter Limit:", ["Miter Limit:", "Limite de pointe :", "Miter Limit:", "Miter Limit:", "斜接限制:"]), ( @@ -3501,8 +3516,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Ukrainian", ["Ukrainian", "Ukrainien", "Ukrainian", "Ukrainian", "乌克兰语"]), ("Cell Style", ["Cell Style", "Style de cellule", "Cell Style", "Cell Style", "单元格样式"]), ("Table Style", ["Table Style", "Style de tableau", "Table Style", "Table Style", "表样式"]), - ("Col ←", ["Col ←", "Col ←", "Col ←", "Col ←", "左侧插入列"]), - ("Col →", ["Col →", "Col →", "Col →", "Col →", "右侧插入列"]), + ("Col ←", ["Col ←", "Col. ←", "Col ←", "Col ←", "左侧插入列"]), + ("Col →", ["Col →", "Col. →", "Col →", "Col →", "右侧插入列"]), ("Row ↑", ["Row ↑", "Ligne ↑", "Row ↑", "Row ↑", "上方插入行"]), ("Row ↓", ["Row ↓", "Ligne ↓", "Row ↓", "Row ↓", "下方插入行"]), ("Hide Others", ["Hide Others", "Masquer les autres", "Hide Others", "Hide Others", "隐藏其他图层"]), @@ -3532,7 +3547,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Show Parent Page Overlay", [ "Überlagerung der übergeordneten Seite anzeigen", - "Afficher la superposition de la page parent", + "Afficher la superposition du gabarit", "Mostrar superposición de página principal", "親ページオーバーレイを表示", "显示父页面叠加", @@ -3540,7 +3555,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ( "Controlled by Parent", - ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le gabarit", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ( @@ -4225,8 +4240,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Bullets and Numbering…", ["Aufzählungszeichen und Nummerierung…", "Puces et numérotation…", "Viñetas y numeración…", "箇条書き記号と番号付け…", "项目符号和编号…"], ), - ("List:", ["Liste:", "Liste:", "Lista:", "リスト:", "列表:"]), - ("Mode:", ["Modus:", "Mode:", "Modo:", "モード:", "模式:"]), + ("List:", ["Liste:", "Liste :", "Lista:", "リスト:", "列表:"]), + ("Mode:", ["Modus:", "Mode :", "Modo:", "モード:", "模式:"]), ( "Continue from Previous Number", [ @@ -4588,217 +4603,198 @@ pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { #[cfg(test)] mod tests { use super::*; + use std::collections::HashSet; + + /// Every per-language table `tr` looks up, by interface language. + const LANGUAGE_TABLES: &[(&str, &[(&str, &str)])] = &[ + ("ar", ar::TABLE), + ("cs", cs::TABLE), + ("de", de::TABLE), + ("fr", fr::TABLE), + ("it", it::TABLE), + ("ja", ja::TABLE), + ("ka", ka::TABLE), + ("pl", pl::TABLE), + ("pt-br", pt_br::TABLE), + ("ru", ru::TABLE), + ("uk", uk::TABLE), + ]; + + /// Every per-language table `tr_context` looks up. + const CONTEXT_TABLES: &[(&str, &[(&str, &str, &str)])] = + &[("ar", ar::CONTEXT), ("cs", cs::CONTEXT), ("ja", ja::CONTEXT), ("pl", pl::CONTEXT), ("ru", ru::CONTEXT), ("uk", uk::CONTEXT)]; + + /// The languages of the shared tables' columns, in column order. + const SHARED_COLUMNS: [&str; 5] = ["de", "fr", "es", "ja", "zh"]; + + /// The German and Japanese tables translate the whole interface and win over the shared columns. + const WHOLE_INTERFACE: [&str; 2] = ["de", "ja"]; + + #[test] + fn every_interface_language_has_a_catalog() { + for (lang, i) in SHARED_COLUMNS.iter().zip(0..) { + assert_eq!(column(lang), Some(i), "{lang}"); + } + for (code, name) in LANGUAGES.iter().filter(|(code, _)| !code.is_empty()) { + assert!(!name.is_empty(), "{code}"); + assert!(LANGUAGE_TABLES.iter().any(|(lang, _)| lang == code) || column(code).is_some(), "{code} has no catalog in LANGUAGE_TABLES"); + } + } #[test] fn cached_lookup_preserves_every_translation_across_language_switches() { + let own = |lang: &str, key: &str| { + LANGUAGE_TABLES.iter().find(|(code, _)| *code == lang).and_then(|(_, table)| table.iter().find(|(k, _)| *k == key)).map(|(_, text)| *text) + }; for (key, translations) in TABLE { - for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) { - // `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns. - if lang != "de" && lang != "ja" { - assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); - } + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(translations) { + // A language's own table is looked up before the shared column. + assert_eq!(tr(lang, key), own(lang, key).unwrap_or(*shared), "{lang}: {key}"); } assert_eq!(tr("", key), *key); } - for (key, expected) in de::TABLE { - assert_eq!(tr("de", key), *expected, "de: {key}"); - } - for (key, expected) in ar::TABLE { - assert_eq!(tr("ar", key), *expected, "ar: {key}"); - } - for (key, expected) in pt_br::TABLE { - assert_eq!(tr("pt-br", key), *expected, "pt-br: {key}"); - } - for (key, expected) in it::TABLE { - assert_eq!(tr("it", key), *expected, "it: {key}"); - } - for (key, expected) in uk::TABLE { - assert_eq!(tr("uk", key), *expected, "uk: {key}"); - } - for (key, expected) in ru::TABLE { - assert_eq!(tr("ru", key), *expected, "ru: {key}"); - } - for (key, expected) in cs::TABLE { - assert_eq!(tr("cs", key), *expected, "cs: {key}"); - } - for (key, expected) in fr::TABLE { - assert_eq!(tr("fr", key), *expected, "fr: {key}"); - } - for (key, expected) in pl::TABLE { - assert_eq!(tr("pl", key), *expected, "pl: {key}"); - } - for (key, expected) in ka::TABLE { - assert_eq!(tr("ka", key), *expected, "ka: {key}"); + for (lang, table) in LANGUAGE_TABLES { + for (key, expected) in *table { + assert_eq!(tr(lang, key), *expected, "{lang}: {key}"); + assert_eq!(tr("", key), *key); + } } + // Strings no catalog has stay as they are, in every language. let unknown = String::from("A user-defined untranslated label"); - for lang in ["pl", "cs", "uk", "ru", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] { - assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en", "unknown"]) { + assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()), "{lang}"); + // Fixed standard names stay identical. + assert_eq!(tr(lang, "PDF/X-4"), "PDF/X-4", "{lang}"); } } #[test] - fn translates_known_strings_and_keeps_the_rest() { - assert_eq!(tr("de", "File"), "Datei"); - assert_eq!(tr("ja", "Swatches"), "スウォッチ"); - assert_eq!(tr("fr", "Something new"), "Something new"); - assert_eq!(tr("", "File"), "File"); - assert_eq!(tr("ar", "File"), "ملف"); - assert_eq!(tr("ar", "Unknown label"), "Unknown label"); - assert_eq!(tr("pt-br", "File"), "Arquivo"); - assert_eq!(tr("pt-br", "New Document…"), "Novo documento…"); - assert_eq!(tr("pt-br", "Unknown label"), "Unknown label"); - assert_eq!(tr("it", "Edit"), "Modifica"); - assert_eq!(tr("it", "New Document…"), "Nuovo documento…"); - assert_eq!(tr("it", "Unknown label"), "Unknown label"); - assert!(is_rtl("ar")); - for (i, (en, translation)) in ar::TABLE.iter().enumerate() { - assert!(ar::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); + fn context_captions_come_from_their_context_or_the_plain_translation() { + for (lang, table) in CONTEXT_TABLES { + for (key, scope, text) in *table { + assert_eq!(tr_context(lang, key, scope), *text, "{lang}: {key} ({scope})"); + } } - for (i, (en, translation)) in pt_br::TABLE.iter().enumerate() { - assert!(pt_br::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); + for (key, scope, row) in CONTEXT { + for (lang, shared) in SHARED_COLUMNS.into_iter().zip(row) { + // A language's own context table is looked up before the shared one. + let own = CONTEXT_TABLES + .iter() + .filter(|(code, _)| *code == lang) + .flat_map(|(_, table)| table.iter()) + .find(|(k, s, _)| k == key && s == scope) + .map(|(_, _, text)| *text); + assert_eq!(tr_context(lang, key, scope), own.unwrap_or(*shared), "{lang}: {key} ({scope})"); + } } - for (i, (en, translation)) in it::TABLE.iter().enumerate() { - assert!(it::TABLE[..i].iter().all(|(key, _)| key != en), "duplicate {en}"); - assert!(!translation.is_empty(), "{en}"); - } - // Italian entries are for keys the shared or Arabic table knows. A new English string - // falls back to English until it is translated, so it doesn't fail here. - for (en, _) in it::TABLE { - assert!(TABLE.iter().any(|(key, _)| key == en) || ar::TABLE.iter().any(|(key, _)| key == en), "it: unknown key {en}"); - } - // Every row is unique and complete. - for (i, (en, t)) in TABLE.iter().enumerate() { - assert!(TABLE[..i].iter().all(|(e, _)| e != en), "duplicate {en}"); - assert!(t.iter().all(|x| !x.is_empty()), "{en}"); + // An unknown context, or a language without one, keeps the plain translation. + let keys = CONTEXT.iter().map(|(key, _, _)| *key).chain(CONTEXT_TABLES.iter().flat_map(|(_, table)| table.iter().map(|(key, _, _)| *key))); + for key in keys { + for lang in LANGUAGES.iter().map(|(code, _)| *code).chain(["en"]) { + assert_eq!(tr_context(lang, key, "no such context"), tr(lang, key), "{lang}: {key}"); + } + assert_eq!(tr_context("", key, "count"), key); } } + /// Every catalog: no duplicate or stale keys, no empty translations, and placeholders, padding, + /// ellipses and label colons as in English. A string a catalog doesn't translate yet falls back + /// to English, so missing translations don't fail here. #[test] - fn a_column_count_and_a_list_number_read_differently() { - for (lang, count, numeral) in [ - ("de", "Anzahl:", "Zahl:"), - ("ja", "数:", "番号:"), - ("uk", "Кількість:", "Номер:"), - ("ar", "العدد:", "الرقم:"), - ("fr", "Nombre :", "Numéro:"), - ("zh", "数量:", "编号:"), - ] { - assert_eq!((tr_context(lang, "Number:", "count"), tr(lang, "Number:")), (count, numeral), "{lang}"); + fn every_catalog_is_unique_current_and_keeps_placeholders_and_markers() { + // (language, key, translation) of every catalog, and every catalog's keys by name. + let mut entries: Vec<(&str, &str, &str)> = Vec::new(); + let mut catalogs: Vec<(&str, HashSet<&str>)> = Vec::new(); + for (lang, table) in LANGUAGE_TABLES { + let keys: HashSet<_> = table.iter().map(|(key, _)| *key).collect(); + assert_eq!(keys.len(), table.len(), "{lang}: duplicate keys"); + catalogs.push((*lang, keys)); + entries.extend(table.iter().map(|(key, text)| (*lang, *key, *text))); } - assert_eq!(tr_context("en", "Number:", "count"), "Number:"); - assert_eq!(tr("de", "Gutter:"), "Spaltenabstand:"); - // The Pages panel menu. - for key in [ - "Insert Pages…", - "Move Pages…", - "New Parent…", - "Override All Parent Page Items", - "Remove All Local Overrides", - "Detach All Objects from Parent", - "Create Alternate Layout…", - "Allow Selected Spread to Shuffle", - ] { - assert_ne!(tr("pt-br", key), key); + let shared: HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); + assert_eq!(shared.len(), TABLE.len(), "duplicate keys in the shared table"); + catalogs.push(("shared", shared.clone())); + for (lang, i) in SHARED_COLUMNS.into_iter().zip(0..) { + entries.extend(TABLE.iter().filter_map(|(key, row)| row.get(i).map(|text| (lang, *key, *text)))); + entries.extend(CONTEXT.iter().filter_map(|(key, _, row)| row.get(i).map(|text| (lang, *key, *text)))); + } + let mut contexts = vec![("shared", CONTEXT.iter().map(|(key, scope, _)| (*key, *scope)).collect::>())]; + for (lang, table) in CONTEXT_TABLES { + contexts.push((*lang, table.iter().map(|(key, scope, _)| (*key, *scope)).collect())); + entries.extend(table.iter().map(|(key, _, text)| (*lang, *key, *text))); } - assert_ne!(tr("ar", "Apply Parent to Pages"), "Apply Parent to Pages"); - } - #[test] - fn french_catalog_has_no_duplicate_or_stale_keys_and_keeps_placeholders() { - let catalog: HashMap<_, _> = fr::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), fr::TABLE.len(), "duplicate French keys"); - let shared: std::collections::HashSet<_> = TABLE.iter().map(|(key, _)| *key).collect(); - // `fr::TABLE` is looked up first: a key there would hide the shared table's French column. - let both: Vec<_> = catalog.keys().filter(|k| shared.contains(*k)).collect(); - assert!(both.is_empty(), "French keys also in the shared table: {both:?}"); - let mut keys = std::collections::HashSet::new(); - for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); + // A per-language table is looked up before the shared table, so a key in both hides the + // shared column, except in the tables that translate the whole interface. + for (lang, table) in LANGUAGE_TABLES.iter().filter(|(lang, _)| column(lang).is_some() && !WHOLE_INTERFACE.contains(lang)) { + let both: Vec<_> = table.iter().map(|(key, _)| *key).filter(|key| shared.contains(key)).collect(); + assert!(both.is_empty(), "{lang} keys also in the shared table: {both:?}"); } - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}"); - let placeholders = |s: &str| { - let mut names = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - names.sort(); + + // A key is current while the interface or another catalog has it. + let interface = interface_strings(); + let current = |key: &str, own: &str| interface.contains(key) || catalogs.iter().any(|(name, keys)| *name != own && keys.contains(key)); + for (name, keys) in &catalogs { + let mut stale: Vec<_> = keys.iter().filter(|key| !current(key, name)).collect(); + stale.sort(); + assert!(stale.is_empty(), "{name}: entries for unknown keys: {stale:?}"); + } + for (name, keys) in &contexts { + let unique: HashSet<_> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "{name}: duplicate context keys"); + let stale: Vec<_> = keys.iter().filter(|(key, _)| !current(key, "")).collect(); + assert!(stale.is_empty(), "{name}: contexts for unknown keys: {stale:?}"); + } + + let placeholders = |s: &'static str| { + let mut names: Vec<_> = s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name)).collect(); + names.sort_unstable(); names }; - let shared_french = TABLE.iter().filter_map(|(key, row)| row.get(1).map(|text| (*key, *text))); - for (key, translation) in fr::TABLE.iter().copied().chain(shared_french) { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); + let ellipsis = |s: &str| s.ends_with('…') || s.ends_with("..."); + let colon = |s: &str| s.ends_with(':') || s.ends_with(':'); + // Full-width punctuation carries its own spacing. + let full_width = |c: char| ('\u{3000}'..='\u{303F}').contains(&c) || ('\u{FF00}'..='\u{FFEF}').contains(&c); + for (lang, key, text) in entries { + assert!(!text.trim().is_empty(), "{lang}: empty translation of {key:?}"); + assert_eq!(placeholders(key), placeholders(text), "{lang}: placeholders of {key:?}"); + // Dialog ellipses, label colons and padded captions stay as in English. + assert_eq!(ellipsis(key), ellipsis(text), "{lang}: ellipsis of {key:?}"); + assert_eq!(colon(key), colon(text), "{lang}: colon of {key:?}"); + if !text.starts_with(full_width) { + assert_eq!(key.starts_with(' '), text.starts_with(' '), "{lang}: leading space of {key:?}"); + } + if !text.ends_with(full_width) { + assert_eq!(key.ends_with(' '), text.ends_with(' '), "{lang}: trailing space of {key:?}"); + } } - assert_eq!(tr("fr", "Group"), "Associer"); } #[test] - fn ukrainian_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: std::collections::HashMap<_, _> = uk::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), uk::TABLE.len(), "duplicate Ukrainian keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - keys.extend(ar::TABLE.iter().map(|(key, _)| *key)); - keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); - // A new English string falls back to English until it is translated, so only stale - // entries (keys no catalog knows any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in uk::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); + fn only_arabic_lays_out_right_to_left() { + for (code, _) in LANGUAGES { + assert_eq!(is_rtl(code), *code == "ar", "{code}"); + } + } + + /// Built-in style and workspace names follow the interface language; names people give their + /// own styles and workspaces are document data and stay as typed. + #[test] + fn only_built_in_style_and_workspace_names_are_translated() { + for (code, _) in LANGUAGES { + for name in [designcraft_doc::NO_PARA_STYLE, designcraft_doc::BASIC_PARAGRAPH, designcraft_doc::NO_CHAR_STYLE] { + assert_eq!(style_name(code, name), tr(code, name), "{code}"); + } + assert_eq!(workspace_name(code, "Essentials"), tr(code, "Essentials"), "{code}"); + for custom in ["Group", "Columns", "My layout style"] { + assert_eq!(style_name(code, custom), custom, "{code}"); + assert_eq!(workspace_name(code, custom), custom, "{code}"); + } } - assert_eq!(tr_context("uk", "Columns", "table"), "Стовпці"); - assert_eq!(tr("uk", "Columns"), "Колонки"); - assert_eq!(tr_context("uk", "Group", "selection"), "Група"); - // Step and Repeat's grid columns are not a text frame's. - assert_eq!(tr_context("uk", "Columns:", "grid"), "Стовпці:"); - assert_eq!(tr_context("ja", "Columns:", "grid"), "列:"); - assert_eq!(tr("ja", "Columns:"), "段:"); - assert_eq!(tr("uk", "Group"), "Згрупувати"); - assert_eq!(tr_context("", "Columns", "table"), "Columns"); - assert_eq!(tr_context("ja", "Span Columns", "paragraphLayout"), "段抜き"); - assert_eq!(tr("ja", "Span Columns"), "段抜きと段分割"); - assert_eq!(tr_context("uk", "Custom caption", "custom"), "Custom caption"); - assert_eq!(tr("uk", "through"), "включно"); - assert_eq!(tr("uk", "up to"), "не включно"); - assert!(!is_rtl("uk")); - assert_eq!(tr("uk", "Type"), "Текст"); - assert_eq!(tr("uk", "Spread"), "Розворот"); - assert_eq!(tr("uk", "Story"), "Текстовий матеріал"); - assert_eq!(tr("uk", "Footer Rows"), "Рядки нижнього колонтитула"); - assert_eq!(style_name("uk", "[Basic Paragraph]"), "[Основний абзац]"); - assert_eq!(style_name("uk", "My layout style"), "My layout style"); - assert_eq!(workspace_name("uk", "Essentials"), "Основне"); - assert_eq!(workspace_name("uk", "My workspace"), "My workspace"); } #[test] - fn georgian_catalog_preserves_placeholders_and_counts() { - let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys"); - let placeholders = |s: &str| { - s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>() - }; - for (key, translation) in ka::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - // Georgian has no capitals in running text: Mtavruli never appears in the interface. - assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}"); - } - assert_eq!(tr("ka", "File"), "ფაილი"); - assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…"); - assert_eq!(tr("ka", "Unknown label"), "Unknown label"); - assert!(!is_rtl("ka")); + fn georgian_counts_use_category_captions_for_every_number() { for n in [1, 2, 5, 21] { assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); } @@ -4816,62 +4812,6 @@ mod tests { assert_eq!(count_label("", "objects", 2), "2 objects"); } - #[test] - fn russian_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - keys.extend(ar::TABLE.iter().map(|(key, _)| *key)); - keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); - keys.extend(it::TABLE.iter().map(|(key, _)| *key)); - keys.extend(ja::TABLE.iter().map(|(key, _)| *key)); - keys.extend(uk::TABLE.iter().map(|(key, _)| *key)); - // A new English string falls back to English until it is translated, so only stale - // entries (keys no catalog knows any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in ru::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - } - } - - #[test] - fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() { - assert!(LANGUAGES.contains(&("ru", "Русский"))); - assert!(!is_rtl("ru")); - assert_eq!(tr("ru", "File"), "Файл"); - assert_eq!(tr("ru", "Type"), "Текст"); - assert_eq!(tr("ru", "Spread"), "Разворот"); - assert_eq!(tr("ru", "Story"), "Материал"); - assert_eq!(tr("ru", "Unknown label"), "Unknown label"); - assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы"); - assert_eq!(tr("ru", "Columns"), "Колонки"); - assert_eq!(tr_context("ru", "Group", "selection"), "Группа"); - assert_eq!(tr("ru", "Group"), "Сгруппировать"); - assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption"); - assert_eq!(tr("ru", "through"), "включительно"); - assert_eq!(tr("ru", "up to"), "не включая"); - assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография"); - assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки"); - assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]"); - assert_eq!(style_name("ru", "My layout style"), "My layout style"); - assert_eq!(workspace_name("ru", "Essentials"), "Основное"); - assert_eq!(workspace_name("ru", "My workspace"), "My workspace"); - // Fixed standard names stay identical. - assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4"); - } - #[test] fn russian_counts_use_category_captions_for_every_number() { assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first("")); @@ -4884,45 +4824,6 @@ mod tests { } } - #[test] - fn polish_catalog_covers_existing_keys_and_preserves_placeholders() { - let catalog: HashMap<_, _> = pl::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), pl::TABLE.len(), "duplicate Polish keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - for table in [ar::TABLE, pt_br::TABLE, ja::TABLE, it::TABLE, uk::TABLE, de::TABLE, fr::TABLE, ru::TABLE, cs::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); - } - // A new English string falls back to English until it is translated; only stale entries fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in pl::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - } - assert!(LANGUAGES.contains(&("pl", "Polski"))); - assert!(!is_rtl("pl")); - assert_eq!(tr("pl", "File"), "Plik"); - assert_eq!(tr("pl", "New Document…"), "Nowy dokument…"); - assert_eq!(tr("pl", "Spread"), "Rozkładówka"); - assert_eq!(tr("pl", "Unknown label"), "Unknown label"); - assert_eq!(style_name("pl", "[Basic Paragraph]"), "[Podstawowy akapit]"); - assert_eq!(workspace_name("pl", "Essentials"), "Podstawowe"); - // A table's columns and grid columns aren't a text frame's; a selection's group isn't the action. - assert_eq!(tr("pl", "Columns"), "Łamy"); - assert_eq!(tr_context("pl", "Columns", "table"), "Kolumny"); - assert_eq!(tr_context("pl", "Columns:", "grid"), "Łamy:"); - assert_eq!(tr("pl", "Group"), "Grupuj"); - assert_eq!(tr_context("pl", "Group", "selection"), "Grupa"); - } - #[test] fn polish_counts_use_category_captions_for_every_number() { for n in [0, 1, 2, 5, 12, 22, 25] { @@ -4933,15 +4834,10 @@ mod tests { assert_eq!(tr("pl", "{pages} Pages in {spreads} Spreads"), "Strony: {pages}, rozkładówki: {spreads}"); } - #[test] - fn czech_catalog_has_no_stale_keys_and_keeps_placeholders_and_markers() { - let catalog: HashMap<_, _> = cs::TABLE.iter().copied().collect(); - assert_eq!(catalog.len(), cs::TABLE.len(), "duplicate Czech keys"); - let mut keys = std::collections::HashSet::new(); - keys.extend(TABLE.iter().map(|(key, _)| *key)); - for table in [ar::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE] { - keys.extend(table.iter().map(|(key, _)| *key)); - } + /// Every English string the interface passes to `tr`, as far as the command and tool tables and + /// the list of UI-only labels know them. + fn interface_strings() -> HashSet<&'static str> { + let mut keys = HashSet::new(); // Command labels and menu names reach `tr` from the engine and the UI command table. for spec in designcraft_engine::command_specs() { keys.insert(spec.label); @@ -4961,6 +4857,8 @@ mod tests { "(unchanged)", "[No Language]", "Cell Number:", + "Differs from this document", + "Digits:", "Change fill to:", "Chinese: Simplified", "Chinese: Traditional", @@ -4996,6 +4894,7 @@ mod tests { "Found", "Grab bag", "Key Object", + "Load Styles", "layer", "layers", "Lines +/−", @@ -5006,14 +4905,17 @@ mod tests { "No model credits were built into this copy.", "Object Style", "Paragraph Style", + "PDF/X-1a:2003", "Press keys…", "Range:", "Redefine Document Style", "Report an issue", "Reverse the order", + "Sort Entries in Alphabetical Order", "Story {story} ← story {parent}", "Story Editor — {count} words", "Text Anchor", + "Transparency Flattener", "Use Document Style Definition", // Status-bar messages. "Alternate layout", @@ -5029,41 +4931,7 @@ mod tests { "{name}: import cancelled while a dialog is open; choose the file again", "{name}: {count} step(s) done", ]); - // A new English string falls back to English until it is translated, so only stale - // entries (keys nothing passes to `tr` any more) fail. - let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); - assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}"); - let placeholders = - |s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>(); - for (key, translation) in cs::TABLE { - assert!(!translation.trim().is_empty(), "empty {key}"); - let mut source = placeholders(key); - let mut translated = placeholders(translation); - source.sort(); - translated.sort(); - assert_eq!(source, translated, "placeholders: {key}"); - // Dialog ellipses, label colons and padded button captions stay as in English. - assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); - assert_eq!(key.ends_with(':'), translation.ends_with(':'), "colon: {key}"); - assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}"); - assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}"); - } - assert!(!is_rtl("cs")); - assert_eq!(tr("cs", "File"), "Soubor"); - assert_eq!(tr("cs", "New Document…"), "Nový dokument…"); - assert_eq!(tr("cs", "Spread"), "Dvoustrana"); - assert_eq!(tr("cs", "Bleed"), "Spadávka"); - assert_eq!(tr("cs", "Leading"), "Proklad"); - assert_eq!(tr("cs", "Unknown label"), "Unknown label"); - assert_eq!(tr_context("cs", "Group", "selection"), "Skupina"); - assert_eq!(tr_context("cs", "Name", "contributors"), "Jméno"); - assert_eq!(tr_context("cs", "Name", "unknown"), "Název"); - assert_eq!(tr("cs", "Group"), "Seskupit"); - assert_eq!(tr_context("cs", "Columns", "table"), tr("cs", "Columns")); - assert_eq!(style_name("cs", "[Basic Paragraph]"), "[Základní odstavec]"); - assert_eq!(style_name("cs", "My layout style"), "My layout style"); - assert_eq!(workspace_name("cs", "Essentials"), "Základní"); - assert_eq!(workspace_name("cs", "My workspace"), "My workspace"); + keys } #[test] @@ -5090,51 +4958,4 @@ mod tests { assert!(forms.contains(&tr("cs", noun)), "{noun}: the catalog row is one of the plural forms"); } } - - #[test] - fn czech_translates_every_tool_name() { - for tool in designcraft_tools::TOOL_GROUPS.iter().flat_map(|group| group.iter()) { - assert_ne!(tr("cs", tool.label), tool.label, "{}", tool.id); - assert_eq!(tr("", tool.label), tool.label); - } - } - - #[test] - fn japanese_vertical_type_commands_are_translated() { - for (en, ja) in [ - ("Story Direction", "組み方向"), - ("Horizontal", "横書き"), - ("Vertical", "縦書き"), - ("Tate-Chu-Yoko", "縦中横"), - ("Ruby…", "ルビ…"), - ("Kenten", "圏点"), - ("Warichu", "割り注"), - ] { - assert_eq!(tr("ja", en), ja); - } - } - - #[test] - fn pdf_export_dialog_strings_are_translated() { - // Translated labels in every supported language. - assert_ne!(tr("zh", "Crop Marks"), "Crop Marks"); - assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF"); - assert_ne!(tr("ar", "Include document bleed"), "Include document bleed"); - assert_ne!(tr("pt-br", "Compress images"), "Compress images"); - // English returns the key unchanged. - assert_eq!(tr("", "Export PDF"), "Export PDF"); - // An unknown string still falls through unchanged. - assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print"); - // Fixed standard names stay identical in every language. - assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4"); - } - - #[test] - fn style_row_menu_strings_are_translated_in_every_language() { - for (code, _) in LANGUAGES.iter().filter(|(c, _)| !c.is_empty()) { - for key in ["Redefine Style", "Alt-click clears overrides", "Apply \"{name}\", Clear Overrides"] { - assert_ne!(tr(code, key), key, "{code}: {key}"); - } - } - } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..31f7fb70d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -147,6 +147,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "تكرار"), ("Select All", "تحديد الكل"), ("Deselect All", "إلغاء تحديد الكل"), + ("Select All Guides", "تحديد كل الأدلة"), + ("Ruler Guide", "دليل المسطرة"), ("Find/Change…", "البحث/التغيير…"), ("Find/Change", "البحث/التغيير"), ("Check Spelling…", "تدقيق إملائي…"), @@ -393,6 +395,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), ("Lock Guides", "قفل الأدلة"), + ("Lock Column Guides", "قفل أدلة الأعمدة"), ("Show Document Grid", "إظهار شبكة المستند"), ("Show Baseline Grid", "إظهار شبكة خطوط الأساس"), ("Options", "خيارات"), @@ -1603,6 +1606,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "تحريك نقاط الارتساء"), ("Move Column", "نقل عمود"), ("Move Guide", "تحريك دليل"), + ("Move Column Guide", "تحريك دليل العمود"), ("Move Layer", "نقل طبقة"), ("Move Pages…", "نقل صفحات…"), ("Move to Layer", "نقل إلى طبقة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..4d41c30dc 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -37,6 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplikovat"), ("Select All", "Vybrat vše"), ("Deselect All", "Zrušit výběr"), + ("Select All Guides", "Vybrat všechna vodítka"), + ("Ruler Guide", "Vodítko pravítka"), ("Find/Change…", "Najít/Změnit…"), ("Check Spelling…", "Zkontrolovat pravopis…"), ("Preferences…", "Předvolby…"), @@ -773,6 +775,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Přesunout kotvy"), ("Move Column", "Přesunout sloupec"), ("Move Guide", "Přesunout vodítko"), + ("Move Column Guide", "Přesunout vodítko sloupce"), ("Move Layer", "Přesunout vrstvu"), ("Move Pages…", "Přesunout stránky…"), ("Move to Layer", "Přesunout do vrstvy"), @@ -1093,6 +1096,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Načíst DTD…"), ("Location:", "Umístění:"), ("Lock Guides", "Zamknout vodítka"), + ("Lock Column Guides", "Zamknout vodítka sloupců"), ("Miter Limit:", "Limit ostrého spoje:"), ("New Article from Selection", "Nový článek z výběru"), ("New Note at Cursor", "Nová poznámka u kurzoru"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..4f4177cbd 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -64,6 +64,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), ("Deselect All", "Auswahl aufheben"), + ("Select All Guides", "Alle Hilfslinien auswählen"), + ("Ruler Guide", "Hilfslinie"), ("Find/Change…", "Suchen/Ersetzen …"), ("Check Spelling…", "Rechtschreibprüfung …"), ("Preferences…", "Voreinstellungen …"), @@ -830,6 +832,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Anker verschieben"), ("Move Column", "Spalte verschieben"), ("Move Guide", "Hilfslinie verschieben"), + ("Move Column Guide", "Spaltenhilfslinie verschieben"), ("Move Layer", "Ebene verschieben"), ("Move Pages…", "Seiten verschieben …"), ("Move to Layer", "Auf Ebene verschieben"), @@ -1151,6 +1154,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD laden …"), ("Location:", "Position:"), ("Lock Guides", "Hilfslinien sperren"), + ("Lock Column Guides", "Spaltenhilfslinien sperren"), ("Miter Limit:", "Gehrungsgrenze:"), ("New Article from Selection", "Neuer Artikel aus Auswahl"), ("New Note at Cursor", "Neue Notiz an der Einfügemarke"), @@ -2033,7 +2037,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Print Layer", "Ebene drucken"), ("Suppress Text Wrap When Layer is Hidden", "Textumfluss bei ausgeblendeter Ebene unterdrücken"), // Remaining interface strings. - ("A custom name", "Ein eigener Name"), ("Close Panel", "Bedienfeld schließen"), ("Default Fill and Stroke", "Standardfläche und -kontur"), ("Distribute Rows Evenly", "Zeilen gleichmäßig verteilen"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..681230d84 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -1,57 +1,118 @@ //! French translations of the strings the shared table in `i18n.rs` has no row for, //! written from the English source using ordinary French desktop-publishing vocabulary. pub(super) const TABLE: &[(&str, &str)] = &[ + ("% of all commits", "% de tous les commits"), + ("(any)", "(quelconque)"), + ("(unchanged)", "(inchangé)"), ("90° CCW", "90° sens antihoraire"), ("90° CW", "90° sens horaire"), + ("About", "À propos"), + ("Activate Panel", "Activer le panneau"), + ("Add Anchor Point Tool", "Outil Ajout de point d'ancrage"), + ("Add Tab", "Ajouter une tabulation"), + ("Align To", "Aligner sur"), ("Alignment", "Alignement"), ("All Documents", "Tous les documents"), ("Alpha Channel", "Couche alpha"), + ("Alternate layout", "Variante de mise en page"), ("Anchored Object Options", "Options d'objet ancré"), + ("Apply Numbering & Section Options", "Appliquer les options de numérotation et de section"), + ("Apply Text Attributes (Eyedropper)", "Appliquer les attributs de texte (Pipette)"), ("ArtCraft App Page", "Page de l'application ArtCraft"), ("ArtCraft Website", "Site web d'ArtCraft"), ("Auto Size", "Taille automatique"), + ("Auto Tate-Chu-Yoko", "Tate-Chu-Yoko automatique"), + ("Binary assets added", "Fichiers binaires ajoutés"), + ("Binary assets removed", "Fichiers binaires supprimés"), ("Binding", "Reliure"), + ("Break Thread", "Rompre le chaînage"), ("Bullet Character", "Caractère de puce"), ("CMYK:", "CMJN :"), + ("Cancel Preview", "Annuler l'aperçu"), + ("Cell Number:", "Numéro de mobile :"), + ("Change Panel Layout", "Modifier la disposition des panneaux"), + ("Change Tab", "Modifier la tabulation"), ("Change To", "Remplacer par"), + ("Change fill to:", "Remplacer le fond par :"), + ("Character Style", "Style de caractère"), ("Check Spelling", "Vérifier l'orthographe"), + ("Chinese: Simplified", "Chinois : simplifié"), + ("Chinese: Traditional", "Chinois : traditionnel"), + ("Clear All Tabs", "Effacer toutes les tabulations"), + ("Clear Object Style Overrides", "Effacer les remplacements du style d'objet"), ("Clear Rotation", "Effacer la rotation"), + ("Clipping Path Options…", "Options de masque…"), + ("Close Panel", "Fermer le panneau"), + ("Close Window", "Fermer la fenêtre"), ("Closed Path", "Tracé fermé"), ("Color Blindness – Deuteranopia", "Daltonisme – Deutéranopie"), ("Color Blindness – Protanopia", "Daltonisme – Protanopie"), + ("Color Theme Tool", "Outil Thème de couleur"), + ("Color Theme…", "Thème de couleur…"), ("Colour", "Couleur"), ("Colour Picker", "Sélecteur de couleurs"), ("Column Break", "Saut de colonne"), + ("Commit Preview", "Valider l'aperçu"), ("Community", "Communauté"), + ("Company", "Entreprise"), ("Conditional Text", "Texte conditionnel"), + ("Content Collector Tool", "Outil Collecteur de contenu"), + ("Content Placer Tool", "Outil Placeur de contenu"), + ("Contributors", "Contributeurs"), + ("Convert Direction Point Tool", "Outil Conversion de point directeur"), + ("Convert Point", "Convertir le point"), + ("Convert Rows", "Convertir les lignes"), ("Copyright Symbol", "Symbole du copyright"), ("Corner Options", "Options d'arrondi"), + ("Couldn't write recovery data: {error}", "Impossible d'écrire les données de récupération : {error}"), ("Create Guides", "Créer des repères"), ("Dark", "Foncé"), + ("Data Fields", "Champs de données"), + ("Data Merge Options", "Options de fusion des données"), ("Define Cross-Reference Format", "Définir un format de référence croisée"), ("Define Lists", "Définir des listes"), ("Define Text Variable", "Définir une variable de texte"), + ("Delete Anchor Point Tool", "Outil Suppression de point d'ancrage"), + ("Delete Character Style", "Supprimer le style de caractère"), + ("Delete Document Preset", "Supprimer le paramètre prédéfini de document"), + ("Delete Parent", "Supprimer le gabarit"), ("Delete Type from Path", "Supprimer le texte curviligne"), + ("Deselect Guides", "Désélectionner les repères"), ("DesignCraft Website", "Site web de DesignCraft"), ("DesignCraft on GitHub", "DesignCraft sur GitHub"), + ("DesignCraft page", "Page DesignCraft"), ("Detect Edges", "Détecter les contours"), + ("Direct Selection Tool", "Outil Sélection directe"), ("Discretionary Hyphen", "Trait d'union conditionnel"), + ("Display name", "Nom affiché"), + ("Distribute Rows Evenly", "Répartir les lignes uniformément"), ("Document CMYK", "CMJN du document"), ("Document Endnote Options", "Options de notes de fin de document"), ("Document Footnote Options", "Options de notes de bas de page du document"), ("Document RGB", "RVB du document"), + ("Don't Save", "Ne pas enregistrer"), ("Done", "Terminé"), ("Double Left Quotation Marks", "Guillemets doubles ouvrants"), ("Double Right Quotation Marks", "Guillemets doubles fermants"), + ("Drag Rows or Columns", "Faire glisser des lignes ou des colonnes"), ("Edit/Interface Language", "Édition/Langue de l'interface"), ("Edit/Spelling", "Édition/Orthographe"), ("Edit/Transparency Blend Space", "Édition/Espace de fusion de la transparence"), ("Edit/Transparency Flattener Presets", "Édition/Préréglages d'aplatissement de la transparence"), + ("Ellipse Frame Tool", "Outil Bloc elliptique"), + ("Ellipse Tool", "Outil Ellipse"), ("Ellipsis", "Points de suspension"), ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), + ("Email Address:", "Adresse e-mail :"), + ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), + ( + "Equal fixed heights for the selected rows; cell text may become overset.", + "Hauteurs fixes identiques pour les lignes sélectionnées ; le texte des cellules peut déborder.", + ), + ("Erase Tool", "Outil Gomme"), ("Export EPUB", "Exporter en EPUB"), ("Export EPUB (Fixed Layout)", "Exporter en EPUB (mise en page fixe)"), ("Export EPUB (Reflowable)", "Exporter en EPUB (redistribuable)"), @@ -59,50 +120,99 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Export IDML", "Exporter en IDML"), ("Export PDF (Interactive)", "Exporter en PDF (interactif)"), ("Export Page as PNG", "Exporter la page en PNG"), + ("Export PowerPoint…", "Exporter en PowerPoint…"), ("Export Text", "Exporter le texte"), ("Export XML", "Exporter en XML"), + ( + "Export a PDF, IDML or package to share — no cloud account needed.", + "Exportez un PDF, un fichier IDML ou un assemblage à partager — aucun compte cloud requis.", + ), + ("Extract Color Theme", "Extraire le thème de couleur"), + ("Eyedropper Tool", "Outil Pipette"), ("Figure Space", "Espace chiffre"), ("File/Export", "Fichier/Exporter"), ("Find What", "Rechercher"), + ("Find fill:", "Rechercher le fond :"), + ("First commit", "Premier commit"), ("Flush Space", "Espace sans alinéa"), ("Forced Line Break", "Saut de ligne forcé"), + ("Formatting Affects Text", "La mise en forme agit sur le texte"), + ("Found", "Trouvé"), ("Frame Break", "Saut de bloc"), + ("Free Transform Tool", "Outil Transformation manuelle"), ("GREP", "GREP"), + ("Gap Tool", "Outil Espace"), ("Generate Index", "Générer l'index"), ("Generate Live Caption", "Générer une légende dynamique"), + ("Get Cell Stroke", "Obtenir le contour de cellule"), + ("Get Tabs", "Obtenir les tabulations"), ("Global Light", "Lumière globale"), + ("Grab bag", "Liste"), + ("Gradient Feather Tool", "Outil Contour progressif dégradé"), + ("Gradient Swatch Tool", "Outil Dégradé de nuance"), ("Graphic", "Graphique"), + ("Group with", "Grouper avec"), ("Hair Space", "Espace ultrafine"), + ("Hand Tool", "Outil Main"), ("Hide Guides", "Masquer les repères"), ("High Contrast", "Contraste élevé"), ("High Resolution", "Haute résolution"), ("I, II, III, IV...", "I, II, III, IV..."), + ("Import InDesign Shortcut Set", "Importer un jeu de raccourcis InDesign"), ("Import XML", "Importer XML"), + ("Import cancelled: the originating document has closed", "Importation annulée : le document d'origine a été fermé"), ("Indent", "Retrait"), ("Indent to Here", "Retrait jusqu'à ce point"), ("Ink Limit 300%", "Limite d'encre 300 %"), ("Insert Anchored Object", "Insérer un objet ancré"), ("Insert Cross-Reference", "Insérer une référence croisée"), + ("Insert Field", "Insérer un champ"), ("Insert Math Expression", "Insérer une expression mathématique"), ("Insert Pages", "Insérer des pages"), ("Internet Standard RGB (sRGB)", "RVB standard Internet (sRGB)"), + ("Invalid panel layout; restored the default dock", "Disposition des panneaux non valide ; disposition par défaut restaurée"), + ("Join", "Joindre"), ("Join the ArtCraft Discord", "Rejoindre le Discord d'ArtCraft"), ("Join the community", "Rejoindre la communauté"), ("Kerning", "Crénage"), + ("Kerning/Tracking Step", "Pas de crénage/d'approche"), + ("Key Object", "Objet clé"), + ("Last commit", "Dernier commit"), ("Layout/Index", "Page/Index"), ("Layout/Pages", "Page/Pages"), ("Legacy Macintosh RGB", "RVB Macintosh hérité"), ("Light", "Clair"), + ("Line Tool", "Outil Trait"), + ("Line delta", "Différence de lignes"), + ("Lines +/−", "Lignes +/−"), + ("Lines added", "Lignes ajoutées"), + ("Lines deleted", "Lignes supprimées"), + ("List Composite Fonts", "Lister les polices composites"), + ("List Styles in File", "Lister les styles du fichier"), ("Load DTD", "Charger la DTD"), + ("Load Styles…", "Charger les styles…"), ("Load Swatches", "Charger des nuances"), ("Low Resolution", "Basse résolution"), ("Margins:", "Marges :"), + ("Maximize/Restore Window", "Agrandir/Restaurer la fenêtre"), + ("Measure Tool", "Outil Mesure"), ("Medium Dark", "Moyennement foncé"), ("Medium Light", "Moyennement clair"), ("Medium Resolution", "Résolution moyenne"), ("Menus", "Menus"), + ("Merged PRs", "PR fusionnées"), + ("Message:", "Message :"), + ("Minimize Window", "Réduire la fenêtre"), + ("Models", "Modèles"), ("Monitor RGB", "RVB du moniteur"), + ("Move Column Guide", "Déplacer le repère de colonne"), + ("Move Guides", "Déplacer les repères"), ("Move Pages", "Déplacer des pages"), + ("Move Panel", "Déplacer le panneau"), + ("Move Tab", "Déplacer la tabulation"), + ("Move Window", "Déplacer la fenêtre"), + ("Name (A–Z)", "Nom (A–Z)"), + ("New Cell Style", "Nouveau style de cellule"), ("New Condition", "Nouvelle condition"), ("New Hyperlink", "Nouvel hyperlien"), ("New Hyperlink Destination", "Nouvelle destination d'hyperlien"), @@ -110,13 +220,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("New Stroke Style", "Nouveau style de contour"), + ("New Table Style", "Nouveau style de tableau"), ("Next Page Number", "Numéro de la page suivante"), + ("No contributor data was built into this copy.", "Aucune donnée sur les contributeurs n'est intégrée à cette copie."), + ("No matches", "Aucune correspondance"), + ("No model credits were built into this copy.", "Aucun crédit de modèle n'est intégré à cette copie."), ("Nonbreaking Hyphen", "Trait d'union insécable"), ("Nonbreaking Space", "Espace insécable"), ("None (keep transparency)", "Aucun (conserver la transparence)"), + ("Note Tool", "Outil Note"), ("Number of Pages", "Nombre de pages"), ("Numbering & Section Options", "Options de numérotation et de section"), ("OK", "OK"), + ("Object Style", "Style d'objet"), + ("Object Style Options…", "Options de style d'objet…"), ("Object Styles", "Styles d'objet"), ("Object/Anchored Object", "Objet/Objet ancré"), ("Object/Arrange", "Objet/Disposition"), @@ -132,64 +249,130 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object/Transform", "Objet/Transformation"), ("Object/Transform Again", "Objet/Répéter la transformation"), ("On", "Activé"), + ("Organization:", "Organisation :"), ("Orientation", "Orientation"), + ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), + ("PRs", "PR"), ("Package", "Assemblage"), + ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), + ("Page Options", "Options de page"), + ("Page Tool", "Outil Page"), ("Panels", "Panneaux"), ("Paragraph Return", "Retour de paragraphe"), + ("Paragraph Style", "Style de paragraphe"), ("Paragraph Symbol", "Symbole de paragraphe"), + ("Parent Options…", "Options de gabarit…"), + ("Pen Tool", "Outil Plume"), + ("Pencil Tool", "Outil Crayon"), + ("Phone:", "Téléphone :"), ("Picas", "Picas"), + ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), + ("Polygon Frame Tool", "Outil Bloc polygonal"), + ("Polygon Tool", "Outil Polygone"), ("Preflight", "Contrôle en amont"), ("Preflight Panel…", "Panneau Contrôle en amont…"), + ("Press keys…", "Appuyez sur des touches…"), + ("Preview Change", "Prévisualiser la modification"), + ("Preview Record", "Aperçu de l'enregistrement"), ("Previous Page Number", "Numéro de la page précédente"), ("Print Booklet", "Imprimer le cahier"), ("Punctuation Space", "Espace de ponctuation"), ("Quarter Space", "Quart d'espace"), ("Quick Apply", "Application rapide"), ("RGB:", "RVB :"), + ("Range:", "Plage :"), + ("Real name", "Nom réel"), + ("Recent Files", "Fichiers récents"), + ("Rectangle Frame Tool", "Outil Bloc rectangulaire"), + ("Rectangle Tool", "Outil Rectangle"), + ("Redefine Character Style", "Redéfinir le style de caractère"), + ("Redefine Paragraph Style", "Redéfinir le style de paragraphe"), ("Registered Trademark Symbol", "Symbole de marque déposée"), ("Relink", "Rééditer le lien"), ("Relink to Folder", "Rééditer le lien au dossier"), + ("Relinked {count}", "Liens rétablis : {count}"), + ("Reload Fonts", "Recharger les polices"), + ("Remove Data Source", "Supprimer la source de données"), + ("Remove Field", "Supprimer le champ"), + ("Remove Frame from Thread", "Retirer le bloc du chaînage"), + ("Reorder Objects", "Réorganiser les objets"), ("Report an Issue", "Signaler un problème"), + ("Report an issue", "Signaler un problème"), + ("Reset Bar Position", "Réinitialiser la position de la barre"), + ("Resize Row or Column", "Redimensionner la ligne ou la colonne"), + ("Resize Window", "Redimensionner la fenêtre"), + ("Resize panel window", "Redimensionner la fenêtre du panneau"), + ("Resize panels", "Redimensionner les panneaux"), + ("Reverse the order", "Inverser l'ordre"), ("Right Indent Tab", "Tabulation de retrait à droite"), + ("Rotate Tool", "Outil Rotation"), ("Rotation", "Rotation"), + ("Ruler Zero Point", "Point zéro des règles"), ("Save As", "Enregistrer sous"), ("Save Swatches for Exchange", "Enregistrer les nuances pour échange"), ("Save a Copy", "Enregistrer une copie"), + ("Scale Tool", "Outil Mise à l'échelle"), + ("Scissors Tool", "Outil Ciseaux"), ("Scripts", "Scripts"), ("Section Marker", "Marque de section"), ("Section Symbol", "Symbole de section"), + ("Select Guide", "Sélectionner le repère"), + ("Select Page", "Sélectionner la page"), + ("Select Paragraph", "Sélectionner le paragraphe"), + ("Select Sentence", "Sélectionner la phrase"), + ("Select Story", "Sélectionner l'article"), + ("Select Text Unit", "Sélectionner une unité de texte"), ("Selection", "Sélection"), + ("Selection Tool", "Outil Sélection"), + ("Sent {count} page(s) to {printer}", "{count} page(s) envoyée(s) à {printer}"), ("Sentence case", "Majuscule en début de phrase"), + ("Set Composite Font", "Définir la police composite"), + ("Set OpenType Features", "Définir les fonctions OpenType"), + ("Shear Tool", "Outil Déformation"), ("Simulate Paper Color", "Simuler la couleur du papier"), ("Single Left Quotation Mark", "Guillemet simple ouvrant"), ("Single Right Quotation Mark", "Guillemet simple fermant"), ("Sixth Space", "Sixième d'espace"), ("Smart Guide Options", "Options des repères commentés"), + ("Smooth Tool", "Outil Lissage"), ("Space After", "Espace après"), ("Space Before", "Espace avant"), ("Start Page #", "N° de la première page"), ("Step and Repeat", "Exécuter et répéter"), + ("Stop Preview", "Arrêter l'aperçu"), ("Story Column Direction", "Sens des colonnes de l'article"), + ("Story Editor — {count} words", "Mode éditeur — {count} mots"), + ("Story {story} ← story {parent}", "Article {story} ← article {parent}"), ("Straight Double Quotation Marks", "Guillemets droits doubles"), ("Straight Single Quotation Mark (Apostrophe)", "Guillemet droit simple (apostrophe)"), + ("Subject:", "Objet :"), ("Subscript:", "Indice :"), ("Superscript:", "Exposant :"), + ("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"), ("Tab", "Tabulation"), ("Table of Contents", "Table des matières"), ("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"), ("Table/Delete", "Tableau/Supprimer"), ("Table/Insert", "Tableau/Insérer"), ("Table/Select", "Tableau/Sélectionner"), + ("Text Anchor", "Ancre de texte"), + ("Text:", "Texte :"), ("Thin Space", "Espace fine"), ("Third Space", "Tiers d'espace"), + ("Thread Text Frames", "Chaîner les blocs de texte"), ("Title Case", "Majuscule à chaque mot"), + ("To Body", "En lignes de corps"), + ("To Footer", "En lignes de pied"), + ("To Header", "En lignes d'en-tête"), ("Trademark Symbol", "Symbole de marque"), ("Transform Again Individually", "Répéter la transformation individuellement"), ("Transform Sequence Again", "Répéter la séquence de transformations"), ("Transform Sequence Again Individually", "Répéter la séquence de transformations individuellement"), + ("Type Tool", "Outil Texte"), ("Type on a Path Options", "Options de texte curviligne"), + ("Type on a Path Tool", "Outil Texte curviligne"), ("Type/Bulleted and Numbered Lists", "Texte/Listes à puces et numérotées"), ("Type/Change Case", "Texte/Modifier la casse"), ("Type/Hyperlinks & Cross-References", "Texte/Hyperliens et références croisées"), @@ -210,8 +393,20 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("URL:", "URL :"), ("Unassigned", "Non attribué"), ("Units", "Unités"), + ("Unsaved Changes", "Modifications non enregistrées"), + ("Update Data Source", "Mettre à jour la source de données"), + ("Username", "Nom d'utilisateur"), + ("Validate from Root Element", "Valider à partir de l'élément racine"), + ("Vertical Type Tool", "Outil Texte vertical"), + ("Vertical Type on a Path Tool", "Outil Texte curviligne vertical"), ("View/Display Performance", "Affichage/Performances d'affichage"), + ("View/Extras", "Affichage/Extras"), ("View/Grids & Guides", "Affichage/Grilles et repères"), + ("View/Proof Setup", "Affichage/Format d'épreuve"), + ("View/Rotate Spread", "Affichage/Faire pivoter la planche"), + ("View/Screen Mode", "Affichage/Mode de l'écran"), + ("View/Separations Preview", "Affichage/Aperçu des séparations"), + ("View/Structure", "Affichage/Structure"), ("Window/Arrange", "Fenêtre/Disposition"), ("Window/Interactive", "Fenêtre/Interactif"), ("Window/Interface Color Theme", "Fenêtre/Thème de couleur de l'interface"), @@ -220,6 +415,31 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Window/Type & Tables", "Fenêtre/Texte et tableaux"), ("Window/Utilities", "Fenêtre/Utilitaires"), ("Working CMYK", "CMJN de travail"), + ("Zoom Tool", "Outil Zoom"), + ("[No Language]", "[Aucune langue]"), ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("image editing", "retouche d'images"), + ("layer", "calque"), + ("layers", "calques"), ("lowercase", "minuscules"), + ("motion graphics and visual effects", "animation graphique et effets visuels"), + ("photo library and raw development", "photothèque et développement raw"), + ("reading, organizing and protecting PDFs", "lecture, organisation et protection de PDF"), + ("the default printer", "l'imprimante par défaut"), + ("vector illustration", "illustration vectorielle"), + ("video editing, color and sound", "montage vidéo, couleur et son"), + ("{contributors} contributors · {commits} commits", "{contributors} contributeurs · {commits} commits"), + ("{count} conflict(s): {names}", "{count} conflit(s) : {names}"), + ("{count} match(es)", "{count} correspondance(s)"), + ("{count} replacement(s) made", "{count} remplacement(s) effectué(s)"), + ("{label}: {name} (double-click: Color Picker)", "{label} : {name} (double-clic : Sélecteur de couleurs)"), + ( + "{name}: import cancelled while a dialog is open; choose the file again", + "{name} : importation annulée car une boîte de dialogue était ouverte ; choisissez à nouveau le fichier", + ), + ("{name}: {count} step(s) done", "{name} : {count} étape(s) effectuée(s)"), + ( + "“{}” has changes that are not saved. Save them before closing?", + "« {} » contient des modifications non enregistrées. Les enregistrer avant de fermer ?", + ), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..755272fe5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -143,6 +143,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), ("Deselect All", "Deseleziona tutto"), + ("Select All Guides", "Seleziona tutte le guide"), + ("Ruler Guide", "Guida del righello"), ("Find/Change…", "Trova/Sostituisci…"), ("Check Spelling…", "Controllo ortografico…"), ("Preferences…", "Preferenze…"), @@ -903,6 +905,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Sposta ancoraggi"), ("Move Column", "Sposta colonna"), ("Move Guide", "Sposta guida"), + ("Move Column Guide", "Sposta guida colonna"), ("Move Layer", "Sposta livello"), ("Move Pages…", "Sposta pagine…"), ("Move to Layer", "Sposta su livello"), @@ -1233,6 +1236,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Carica DTD…"), ("Location:", "Posizione:"), ("Lock Guides", "Blocca guide"), + ("Lock Column Guides", "Blocca guide colonne"), ("Miter Limit:", "Limite spigolo:"), ("New Article from Selection", "Nuovo articolo dalla selezione"), ("New Note at Cursor", "Nuova nota al cursore"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..e23ef15c7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -145,6 +145,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "複製"), ("Select All", "すべてを選択"), ("Deselect All", "すべての選択を解除"), + ("Select All Guides", "すべてのガイドを選択"), + ("Ruler Guide", "定規ガイド"), ("Find/Change…", "検索と置換…"), ("Check Spelling…", "スペルチェック…"), ("Preferences…", "環境設定…"), @@ -1018,6 +1020,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "アンカーポイントを移動"), ("Move Column", "列を移動"), ("Move Guide", "ガイドを移動"), + ("Move Column Guide", "列ガイドを移動"), ("Move Layer", "レイヤーを移動"), ("Move Pages…", "ページを移動…"), ("Move to Layer", "レイヤーへ移動"), @@ -1349,6 +1352,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD を読み込み…"), ("Location:", "位置:"), ("Lock Guides", "ガイドをロック"), + ("Lock Column Guides", "列ガイドをロック"), ("Miter Limit:", "マイターの上限:"), ("New Article from Selection", "選択範囲から新規アーティクルを作成"), ("New Note at Cursor", "カーソル位置に新規注釈"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..0300b62d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -66,6 +66,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "დუბლირება"), ("Select All", "ყველაფრის მონიშვნა"), ("Deselect All", "ყველა მონიშვნის მოხსნა"), + ("Select All Guides", "ყველა დამხმარე ხაზის მონიშვნა"), + ("Ruler Guide", "სახაზავის დამხმარე ხაზი"), ("Find/Change…", "ძებნა/ჩანაცვლება…"), ("Check Spelling…", "მართლწერის შემოწმება…"), ("Preferences…", "პარამეტრები…"), @@ -835,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "საყრდენი წერტილების გადაადგილება"), ("Move Column", "სვეტის გადატანა"), ("Move Guide", "დამხმარე ხაზის გადაადგილება"), + ("Move Column Guide", "სვეტის დამხმარე ხაზის გადაადგილება"), ("Move Layer", "შრის გადაადგილება"), ("Move Pages…", "გვერდების გადატანა…"), ("Move to Layer", "შრეზე გადატანა"), @@ -1170,6 +1173,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "DTD-ის ჩატვირთვა…"), ("Location:", "მდებარეობა:"), ("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("Lock Column Guides", "სვეტების დამხმარე ხაზების ჩაკეტვა"), ("Miter Limit:", "მახვილის ზღვარი:"), ("New Article from Selection", "ახალი სტატია მონიშნულიდან"), ("New Note at Cursor", "ახალი შენიშვნა კურსორთან"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..7d1d12c67 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), ("Deselect All", "Usuń zaznaczenie"), + ("Select All Guides", "Zaznacz wszystkie linie pomocnicze"), + ("Ruler Guide", "Linia pomocnicza"), ("Find/Change…", "Znajdź/zmień…"), ("Check Spelling…", "Sprawdź pisownię…"), ("Preferences…", "Preferencje…"), @@ -786,6 +788,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Przesuń kotwice"), ("Move Column", "Przesuń kolumnę"), ("Move Guide", "Przesuń linię pomocniczą"), + ("Move Column Guide", "Przesuń linię pomocniczą łamu"), ("Move Layer", "Przesuń warstwę"), ("Move Pages…", "Przesuń strony…"), ("Move to Layer", "Przenieś na warstwę"), @@ -1103,6 +1106,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Wczytaj DTD…"), ("Location:", "Położenie:"), ("Lock Guides", "Zablokuj linie pomocnicze"), + ("Lock Column Guides", "Zablokuj linie pomocnicze łamów"), ("Miter Limit:", "Limit ostrych narożników:"), ("New Article from Selection", "Nowy artykuł z zaznaczenia"), ("New Note at Cursor", "Nowa notatka przy kursorze"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..f552e9a63 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -154,6 +154,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Duplicar"), ("Select All", "Selecionar tudo"), ("Deselect All", "Desmarcar tudo"), + ("Select All Guides", "Selecionar todas as guias"), + ("Ruler Guide", "Guia da régua"), ("Find/Change…", "Localizar/alterar…"), ("Find/Change", "Localizar/alterar"), ("Check Spelling…", "Verificar ortografia…"), @@ -401,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), ("Lock Guides", "Bloquear guias"), + ("Lock Column Guides", "Bloquear guias de colunas"), ("Show Document Grid", "Mostrar grade do documento"), ("Show Baseline Grid", "Mostrar grade de linha de base"), ("Options", "Opções"), @@ -1730,6 +1733,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Mover âncoras"), ("Move Column", "Mover coluna"), ("Move Guide", "Mover guia"), + ("Move Column Guide", "Mover guia de coluna"), ("Move Layer", "Mover camada"), ("Move to Layer", "Mover para a camada"), ("New Article", "Novo artigo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..3f6ef1be0 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -37,6 +37,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), ("Deselect All", "Снять выделение"), + ("Select All Guides", "Выделить все направляющие"), + ("Ruler Guide", "Направляющая"), ("Find/Change…", "Найти/заменить…"), ("Check Spelling…", "Проверка орфографии…"), ("Preferences…", "Установки…"), @@ -760,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Переместить опорные точки"), ("Move Column", "Переместить столбец"), ("Move Guide", "Переместить направляющую"), + ("Move Column Guide", "Переместить направляющую колонки"), ("Move Layer", "Переместить слой"), ("Move Pages…", "Переместить страницы…"), ("Move to Layer", "Переместить на слой"), @@ -1080,6 +1083,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Загрузить DTD…"), ("Location:", "Расположение:"), ("Lock Guides", "Заблокировать направляющие"), + ("Lock Column Guides", "Заблокировать направляющие колонок"), ("Miter Limit:", "Ограничение острого угла:"), ("New Article from Selection", "Новая статья из выделенного"), ("New Note at Cursor", "Новая заметка в позиции курсора"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..9c7cc855a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -146,6 +146,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), ("Deselect All", "Зняти всі виділення"), + ("Select All Guides", "Виділити всі напрямні"), + ("Ruler Guide", "Напрямна"), ("Find/Change…", "Знайти й замінити…"), ("Check Spelling…", "Перевірити орфографію…"), ("Preferences…", "Налаштування…"), @@ -906,6 +908,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Перемістити опорні точки"), ("Move Column", "Перемістити стовпець"), ("Move Guide", "Перемістити напрямну"), + ("Move Column Guide", "Перемістити напрямну колонки"), ("Move Layer", "Перемістити шар"), ("Move Pages…", "Перемістити сторінки…"), ("Move to Layer", "Перемістити до шару"), @@ -1230,6 +1233,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Load DTD…", "Завантажити DTD…"), ("Location:", "Розташування:"), ("Lock Guides", "Заблокувати напрямні"), + ("Lock Column Guides", "Заблокувати напрямні колонок"), ("Miter Limit:", "Межа гострого з’єднання:"), ("New Article from Selection", "Нова стаття з виділення"), ("New Note at Cursor", "Нова примітка в позиції курсора"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d1a915a0..03e64fe6e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +mod shortcut_import; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -296,6 +297,7 @@ pub struct UiState { pub align_to: String, /// Properties > Text Style tab: 0 = Paragraph Styles, 1 = Character Styles. pub text_style_tab: u8, + /// View › Grids & Guides › Lock Guides: ruler guides can't be dragged. pub guides_locked: bool, pub smart_guides: bool, #[serde(default = "default_true")] @@ -457,6 +459,7 @@ impl UiState { smart_dimensions: self.smart_dimensions, smart_spacing: self.smart_spacing, zone_px: self.snap_zone, + guides_locked: self.guides_locked, } } } @@ -808,7 +811,7 @@ impl DesignApp { Some(t.to_string()) } None if !self.native_menu => { - self.object_clip_text = Some(self.copied_objects_text()); + self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session)); self.object_clip_text.clone() } None => { @@ -908,35 +911,6 @@ impl DesignApp { params } - /// Plain text for the system clipboard after copying objects: the copied text frames' stories, - /// or U+FFFC (object replacement character) when none has text. - fn copied_objects_text(&self) -> String { - let mut stories = Vec::new(); - if let Some(clip) = &self.session.clipboard - && let Some(sp) = clip.spreads.first() - { - for it in &sp.items { - it.walk(&mut |i| { - if let Some(tf) = i.text_frame() - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - }); - } - } - let text = stories - .iter() - .filter_map(|id| self.session.clipboard.as_ref()?.story(*id)) - .map(|st| { - st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::() - }) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n"); - if text.is_empty() { '\u{FFFC}'.to_string() } else { text } - } - /// Handle requests produced by tools/commands (dialogs, view changes, file pickers). pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); @@ -1060,6 +1034,10 @@ impl DesignApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); for file in files { + if file.request.purpose == "shortcuts" { + shortcut_import::import_delivered(self, &file.bytes); + continue; + } if self.ui.dialog.is_some() { self.status( crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again") @@ -2482,7 +2460,7 @@ mod tests { assert_eq!(app.ui.status, "Table: No table selected"); app.ui.language = "cs".into(); app.status_error("Table", "No table selected"); - assert_eq!(app.ui.status, "Tabulka: No table selected"); + assert_eq!(app.ui.status, format!("{}: No table selected", crate::i18n::tr("cs", "Table"))); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..87537abd7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"), ("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"), ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), + ( + "window.importShortcuts", + "Import InDesign Shortcut Set", + None, + "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", + ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ( @@ -180,6 +186,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), + ("view.lockGuides", "Lock Guides", Some("Cmd+Alt+;"), "{on?: bool} — ruler guides can't be dragged; toggles without `on`"), ("view.snapToGuides", "Snap to Guides", Some("Cmd+Shift+;"), "{}"), ("view.snapToDocumentGrid", "Snap to Document Grid", None, "{}"), ("view.smartGuides", "Smart Guides", None, "{}"), @@ -366,6 +373,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:edit.stepAndRepeat", "-", "cmd:edit.selectAll", + "cmd:guide.selectAll", "cmd:edit.deselectAll", "-", "ui:app.findChange", @@ -811,6 +819,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", ">Grids & Guides", "ui:view.guides", + "ui:view.lockGuides", + "cmd:view.lockColumnGuides", "ui:view.snapToGuides", "ui:view.snapToDocumentGrid", "ui:view.smartGuides", @@ -1320,16 +1330,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; - let page = crate::canvas::current_page(app).unwrap_or(0); - let spread = if st.editing_parents { - // The parent spread the canvas shows. - let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); - let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); - json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0))) - } else { - json!(st.doc.page_loc(page).map_or(0, |l| l.0)) - }; + let Some(spread) = guide_spread(app) else { return Some(Err("no document open".into())) }; return Some(app.run("guide.deleteAll", json!({"spread": spread}))); } "app.deletePage" | "app.duplicateSpread" => { @@ -1513,6 +1514,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.rulers), "view.guides" => flag(&mut app.ui.guides), + "view.lockGuides" => { + app.ui.guides_locked = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.guides_locked); + Ok(json!(app.ui.guides_locked)) + } "view.snapToGuides" => flag(&mut app.ui.snap_to_guides), "view.snapToDocumentGrid" => flag(&mut app.ui.snap_to_document_grid), "view.smartGuides" => flag(&mut app.ui.smart_guides), @@ -1706,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) { + (Some(text), _) => text.to_string(), + (None, Some(path)) => match crate::shortcut_import::read_file(path) { + Ok(text) => text, + Err(e) => return Some(Err(e)), + }, + (None, None) => return Some(Err("give the set's path or text".into())), + }; + crate::shortcut_import::import(app, &text) + } "view.snapPreferences" => { let b = |k: &str| p.get(k).and_then(Value::as_bool); let ui = &mut app.ui; @@ -2286,6 +2302,9 @@ pub enum AppMenuItem { command: &'static str, }, Sep, + /// Interface language: a submenu with each UI language in its own name, the current one + /// checked (`app.language {lang}`). + Language, Services, Hide, HideOthers, @@ -2298,6 +2317,7 @@ pub const MAC_APP_MENU: &[AppMenuItem] = &[ AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" }, AppMenuItem::Sep, AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" }, + AppMenuItem::Language, AppMenuItem::Sep, AppMenuItem::Services, AppMenuItem::Sep, @@ -2368,6 +2388,8 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.typicalDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Typical, "view.highQualityDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::High, "view.guides" => app.ui.guides, + "view.lockGuides" => app.ui.guides_locked, + "view.lockColumnGuides" => app.session.active().is_some_and(|d| d.doc.settings.column_guides_locked), "view.snapToGuides" => app.ui.snap_to_guides, "view.snapToDocumentGrid" => app.ui.snap_to_document_grid, "view.smartGuides" => app.ui.smart_guides, @@ -2439,6 +2461,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id.starts_with("fit.") { return enabled(app, "object.fit"); } + if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { + return false; + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -2540,6 +2565,16 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new(&format!("cmd:{id}"), json!({}))); return; } + // Select All Guides: on the spread in view, while guides are shown and not locked. + if params.is_null() && id == "guide.selectAll" { + if app.ui.guides + && !app.ui.guides_locked + && let Some(spread) = guide_spread(app) + { + let _ = app.run(id, json!({"spread": spread})); + } + return; + } let p = if params.is_null() { json!({}) } else { params.clone() }; // Update Link asks before replacing edits made to linked text in the layout. if id == "links.update" { @@ -2549,6 +2584,20 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } +/// The spread the guide commands of the menus act on: the one in view (a parent spread while +/// parents are edited). +fn guide_spread(app: &DesignApp) -> Option { + let st = app.session.active()?; + let page = crate::canvas::current_page(app).unwrap_or(0); + Some(if st.editing_parents { + let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); + let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); + json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0))) + } else { + json!(st.doc.page_loc(page).map_or(0, |l| l.0)) + }) +} + /// A menu button whose popup stays within the viewport and scrolls when needed. /// All menu buttons share this wrapper, including workspace, zoom and panel menus. pub fn menu_button<'a, R>( @@ -2977,7 +3026,7 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } -fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { +pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; for part in sc.split('+').filter(|s| !s.is_empty()) { @@ -3018,6 +3067,20 @@ fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui matches.then_some(k == key && m.shift == modifiers.shift) } +/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into +/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never +/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when +/// the chord is another one, or the native menu's accelerator takes it. +#[cfg(not(target_arch = "wasm32"))] +pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> { + let id = "edit.pasteInPlace"; + if app.native_menu && !app.ui.shortcuts.contains_key(id) { + return None; + } + let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?; + (k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id) +} + /// Global keyboard shortcuts: menu commands and single-key tool shortcuts. pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { // Only a focused text field takes the keys: the canvas (or a button) having focus after a click @@ -3081,9 +3144,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { } } if let Some((id, exact)) = fired { - // The native menu's accelerator fires only for the shortcut's own key and modifiers; the - // Plus key standing in for = never reaches it. - if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) { + // The native menu bar carries the shortcuts in effect, the user's included, and its + // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing + // in for = never reaches it. + if exact && app.native_shortcuts.contains(&id) { continue; // The native menu handles it. } // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's @@ -3303,16 +3367,12 @@ mod tests { } #[test] - fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + fn screen_mode_shows_the_current_mode() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); app.ui.screen_mode = crate::ScreenMode::Preview; assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); - // Every View submenu has its Japanese path (Edit › Menus shows them). - for path in submenu_paths("View") { - assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); - } } fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { @@ -3729,8 +3789,8 @@ mod tests { run_ui(&mut app, "app.language", &json!({"lang": "de"})).unwrap().unwrap(); assert_eq!(app.ui.language, "de"); assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true)); - assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster"); assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err()); + assert_eq!(app.ui.language, "de"); // The language is set in Preferences, not from a menu; without `lang` the command opens it. let mut items = Vec::new(); for (_, entries) in menu_tree() { @@ -3740,153 +3800,23 @@ mod tests { run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); assert_eq!(app.ui.language, "de"); - app.ui.dialog = None; - - run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "zh"); - assert_eq!(crate::i18n::tr(&app.ui.language, "File"), "文件"); - - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "ar"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "ar"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ar"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ar", title), title, "{title}"); - } - // Every menu title has a translation (Japanese never matches the English). - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ja", title), title, "{title}"); - } - - run_ui(&mut app, "app.language", &json!({"lang": "pt-br"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "pt-br"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "pt-br"})), Some(true)); - for (title, _) in menu_tree() { - // "Layout" is the natural Brazilian Portuguese word, so it stays identical - // to English; every other title must be translated. - if title != "Layout" { - assert_ne!(crate::i18n::tr("pt-br", title), title, "{title}"); - } - } - - run_ui(&mut app, "app.language", &json!({"lang": "it"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "it"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "it"})), Some(true)); - for (title, _) in menu_tree() { - // "File" and "Layout" are the usual Italian menu names, so they stay identical - // to English; every other title must be translated. - if title != "File" && title != "Layout" { - assert_ne!(crate::i18n::tr("it", title), title, "{title}"); - } - } - - run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "ka"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); - // Every menu title, item and submenu is translated into Georgian. - let mut untranslated = Vec::new(); - for (title, entries) in menu_tree() { - let mut labels = vec![title.to_string()]; - let mut stack: Vec<&Item> = entries.iter().collect(); - while let Some(entry) = stack.pop() { - match entry { - Item::Sub(label, children) => { - labels.push(label.clone()); - stack.extend(children.iter()); - } - Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), - _ => {} - } - } - // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. - let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); - untranslated - .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); - } - assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); } #[test] - fn ukrainian_language_is_selectable_persisted_and_keeps_document_data() { + fn every_interface_language_is_selectable_persisted_and_keeps_document_data() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова"})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мої слова · Zażółć gęślą jaźń"})).unwrap(); let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "uk"})).unwrap().unwrap(); - assert_eq!(checked(&app, "app.language", &json!({"lang": "uk"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("uk", title), title, "{title}"); + for (code, _) in crate::i18n::LANGUAGES { + run_ui(&mut app, "app.language", &json!({"lang": code})).unwrap().unwrap(); + assert_eq!(app.ui.language, *code); + assert_eq!(checked(&app, "app.language", &json!({"lang": code})), Some(true), "{code}"); + assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(), "{code}"); + let bytes = serde_json::to_vec(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(restored.language, *code); } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "uk"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name"); - } - - #[test] - fn russian_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap(); - assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("ru", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "ru"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("ru", "Русский")), "Preferences lists Russian by its own name"); - } - - #[test] - fn czech_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Příliš žluťoučký kůň"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "cs"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "cs"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "cs"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("cs", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "cs"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Soubor"); - assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name"); - assert!(crate::i18n::LANGUAGES.contains(&("cs", "Čeština")), "Preferences lists Czech by its own name"); - } - - #[test] - fn polish_language_is_selectable_persisted_and_keeps_document_data() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": true})).unwrap(); - app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "Zażółć gęślą jaźń"})).unwrap(); - let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap(); - run_ui(&mut app, "app.language", &json!({"lang": "pl"})).unwrap().unwrap(); - assert_eq!(app.ui.language, "pl"); - assert_eq!(checked(&app, "app.language", &json!({"lang": "pl"})), Some(true)); - assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap()); - for (title, _) in menu_tree() { - assert_ne!(crate::i18n::tr("pl", title), title, "{title}"); - } - let bytes = serde_json::to_vec(&app.ui).unwrap(); - let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(restored.language, "pl"); - assert_eq!(crate::i18n::tr(&restored.language, "File"), "Plik"); - assert!(crate::i18n::LANGUAGES.contains(&("pl", "Polski")), "Preferences lists Polish by its own name"); } #[test] @@ -3945,6 +3875,26 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn lock_guides_and_lock_column_guides_are_checked_in_grids_and_guides() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let view = menu_ids("View"); + assert!(view.contains(&"view.lockGuides") && view.contains(&"view.lockColumnGuides")); + assert_eq!(ui_label("view.lockGuides").and_then(|c| c.1), Some("Cmd+Alt+;")); + // Lock Guides: off at first, toggles, and `on` sets it. + assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(false)); + assert_eq!(app.run("view.lockGuides", json!({})).unwrap(), json!(true)); + assert!(app.ui.guides_locked); + assert_eq!(app.run("view.lockGuides", json!({"on": true})).unwrap(), json!(true)); + assert_eq!(checked(&app, "view.lockGuides", &Value::Null), Some(true)); + assert_eq!(app.run("view.lockGuides", json!({"on": false})).unwrap(), json!(false)); + // Lock Column Guides: the document's setting, on in new documents. + assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(true)); + app.run("view.lockColumnGuides", json!({})).unwrap(); + assert_eq!(checked(&app, "view.lockColumnGuides", &Value::Null), Some(false)); + } + #[test] fn fitting_has_indesigns_shortcuts() { // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the @@ -4506,6 +4456,19 @@ mod tests { let z = app.view().unwrap().zoom; assert_eq!(z > z0, zooms_in, "native menu, {k:?} {m:?}: {z0} -> {z}"); } + // A shortcut the user set runs here until the native menu bar carries it as its key + // equivalent; then only the bar runs it, once. + run_ui(&mut app, "window.setShortcut", &json!({"id": "view.zoomOut", "shortcut": "Cmd+9"})).unwrap().unwrap(); + for (native, zooms_out) in [(false, true), (true, false)] { + if native { + app.native_shortcuts.insert("view.zoomOut".to_string()); + } + let z0 = app.view().unwrap().zoom; + frame(&mut app, key(egui::Key::Num9, egui::Modifiers::COMMAND)); + frame(&mut app, vec![]); + let z = app.view().unwrap().zoom; + assert_eq!(z < z0, zooms_out, "native {native}: {z0} -> {z}"); + } } #[test] @@ -4896,9 +4859,10 @@ mod tests { fn command_search_matches_labels_in_the_interface_language() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "de".into(); - let items = command_search(&app, "gruppieren"); + let label = crate::i18n::tr("de", "Group"); + let items = command_search(&app, &label.to_lowercase()); let group = items.iter().find(|i| i.id == "object.group").unwrap(); - assert_eq!(group.label, "Gruppieren"); + assert_eq!(group.label, label); assert_eq!(group.shortcut.as_deref(), Some("Cmd+G")); assert!(command_search(&app, "group").iter().any(|i| i.id == "object.group"), "English labels match too"); assert!(command_search(&app, " ").is_empty()); @@ -5552,7 +5516,9 @@ mod tests { let (Some(about), Some(settings)) = (pos("help.about"), pos("app.preferences")) else { panic!("About and Settings are in the app menu") }; assert!(about < settings); assert_eq!(MAC_APP_MENU.get(settings - 1), Some(&AppMenuItem::Sep)); - assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Sep)); + // Language follows Settings, as in the suite's other apps, titled in the interface language. + assert_eq!(MAC_APP_MENU.get(settings + 1), Some(&AppMenuItem::Language)); + assert_eq!(MAC_APP_MENU.get(settings + 2), Some(&AppMenuItem::Sep)); let label = |command: &str| { MAC_APP_MENU.iter().find_map(|m| match m { AppMenuItem::Cmd { label, command: c, .. } if *c == command => Some(*label), @@ -5560,7 +5526,6 @@ mod tests { }) }; assert_eq!(label("app.preferences"), Some("Settings…")); - assert_ne!(crate::i18n::tr("de", "Settings…"), "Settings…"); let ids = MAC_APP_MENU.iter().filter_map(|m| if let AppMenuItem::Cmd { id, .. } = m { Some(*id) } else { None }).collect::>(); assert!(ids.iter().enumerate().all(|(i, id)| !ids[..i].contains(id)), "native ids are unique: {ids:?}"); diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index 7c2634e23..0a845a38d 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -64,6 +64,8 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { let selected = info.is_some(); let reference = app.ui.ref_point.min(8); let geometry = info.as_ref().map(|i| i.page_rect); + // One ruler guide selected: X (vertical guide) or Y (horizontal guide) is where it is. + let guide = panels::guide_field(app); group(ui, "reference", 26.0, |ui, rect| { let proxy = Rect::from_center_size(rect.center(), vec2(26.0, 26.0)); if let Some(point) = properties::ref_point_proxy(ui, proxy, reference) { @@ -81,10 +83,17 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { for (column, (label, id, key, index)) in fields.into_iter().enumerate() { let x = column as f32 * 108.0; cell(ui, rect, x, row, 14.0, true, |ui| widgets::caption_w(ui, label, 14.0)); - let value = values.map(|v| v[index]); - if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, selected, |ui| { - NumField::measure(id, value, units).width(86.0).spinner().enabled(selected).show(ui) + let on_guide = guide.filter(|g| g.row() == index); + let value = if guide.is_some() { on_guide.map(|g| g.value) } else { values.map(|v| v[index]) }; + let enabled = selected || on_guide.is_some(); + let unit = on_guide.map_or(units, |g| g.unit); + if let Some(v) = cell(ui, rect, x + 15.0, row, 86.0, enabled, |ui| { + NumField::measure(id, value, unit).width(86.0).spinner().enabled(enabled).show(ui) }) { + if let Some(g) = on_guide { + let _ = app.run("guide.move", g.move_to(v)); + continue; + } set.insert(key.into(), json!(v)); if constrain && column == 1 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..f2229b604 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -117,6 +117,49 @@ pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { st.selection.items.first().and_then(|id| st.doc.item(*id)).map_or(1.0, |it| if stroke { it.stroke.tint } else { it.fill.tint }) } +/// The one selected ruler guide, as the X field (vertical guide) or Y field (horizontal guide) +/// of the Control and Properties panels shows it: measured as the rulers measure, from the zero +/// point (and the page, when rulers restart per page), in that ruler's units. +#[derive(Clone, Copy, Debug)] +pub struct GuideField { + pub guide: designcraft_doc::GuideSel, + pub orientation: designcraft_doc::Orientation, + /// Points from the ruler origin. + pub value: f64, + /// Where `value` 0 is: a spread x for a vertical guide, a y for a horizontal one. + pub origin: f64, + pub unit: designcraft_geom::Unit, +} + +impl GuideField { + /// Which field shows it: 0 for X, 1 for Y. + pub fn row(&self) -> usize { + match self.orientation { + designcraft_doc::Orientation::Vertical => 0, + designcraft_doc::Orientation::Horizontal => 1, + } + } + /// The `guide.move` params that put it at `value` (as the field reads). + pub fn move_to(&self, value: f64) -> Value { + let g = self.guide; + json!({"spread": g.spread, "page": g.page, "index": g.index, "position": value + self.origin}) + } +} + +/// [`GuideField`] when exactly one ruler guide is selected. +pub fn guide_field(app: &DesignApp) -> Option { + let st = app.session.active()?; + let [g] = st.selection.guides.as_slice() else { return None }; + let pg = st.doc.spread(g.spread)?.pages.get(g.page)?; + let guide = pg.guides.get(g.index)?; + let (origin, unit) = match guide.orientation { + designcraft_doc::Orientation::Vertical => (st.doc.ruler_origin(g.spread, guide.position)?.x, st.doc.settings.horizontal_units), + designcraft_doc::Orientation::Horizontal => (st.doc.ruler_origin(g.spread, pg.x + pg.width / 2.0)?.y, st.doc.settings.vertical_units), + }; + let value = guide.position - origin; + value.is_finite().then_some(GuideField { guide: *g, orientation: guide.orientation, value, origin, unit }) +} + pub fn sel_info(app: &DesignApp) -> Option { let st = app.session.active()?; if st.selection.items.is_empty() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..fd0094f9b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -59,6 +59,9 @@ fn selection_title(app: &DesignApp, info: &Option) -> String { if st.selection.text.is_some() { return crate::i18n::tr(&app.ui.language, "Characters").into(); } + if info.is_none() && !st.selection.guides.is_empty() { + return crate::i18n::tr(&app.ui.language, "Ruler Guide").into(); + } match info { None => crate::i18n::tr(&app.ui.language, "No Selection").into(), Some(i) if i.count > 1 => crate::i18n::tr(&app.ui.language, "Multiple Objects").into(), @@ -106,6 +109,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { quick_actions_text(app, ui); return; } + if info.is_none() && app.session.active().is_some_and(|st| !st.selection.guides.is_empty()) { + section(ui, crate::i18n::tr(&app.ui.language, "Transform"), true); + guide_section(app, ui); + return; + } // The Page tool with no objects selected: the page's own settings, as InDesign's. if info.is_none() && app.session.tool_id() == "page" { let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); @@ -513,7 +521,9 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if smart != app.ui.smart_guides { let _ = app.run("view.smartGuides", json!({})); } - app.ui.guides_locked = locked; + if locked != app.ui.guides_locked { + let _ = app.run("view.lockGuides", json!({"on": locked})); + } rule(ui); section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); let (a, b) = button_pair(ui, crate::i18n::tr(&app.ui.language, "Import File"), crate::i18n::tr(&app.ui.language, "Insert Page")); @@ -613,6 +623,27 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } } +/// Properties › Transform for selected ruler guides: X for one vertical guide, Y for one +/// horizontal guide; with several guides both fields are empty. +fn guide_section(app: &mut DesignApp, ui: &mut Ui) { + let guide = super::guide_field(app); + let b = block(ui, 2.0 * FIELD_H + 4.0); + for (row, (label, key)) in [("X:", "gx"), ("Y:", "gy")].into_iter().enumerate() { + let yy = row as f32 * 25.0; + let on = guide.filter(|g| g.row() == row); + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, label, 18.0)); + let typed = place(ui, sub(b, 59.0, yy, 135.0, FIELD_H), |ui| { + if on.is_none() { + ui.disable(); + } + measure(ui, key, on.map(|g| g.value), on.map_or_else(|| units(app), |g| g.unit), 135.0) + }); + if let (Some(v), Some(g)) = (typed, on) { + let _ = app.run("guide.move", g.move_to(v)); + } + } +} + /// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the /// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { @@ -3117,8 +3148,19 @@ mod tests { /// are translated, with a sample of each script's figures. #[test] fn digits_choices_use_the_reference_names() { - let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label("fr", *o)).collect(); - assert_eq!(shown, ["Par défaut", "Arabe 0123", "Hindi ٠١٢٣", "Persan ۰۱۲۳", "Natif"].map(String::from)); + assert_eq!(DIGIT_OPTIONS.map(|o| o.1), ["Default", "Arabic", "Hindi", "Farsi", "Native"]); + for (lang, _) in crate::i18n::LANGUAGES { + let tr = |s| crate::i18n::tr(lang, s); + let shown: Vec = DIGIT_OPTIONS.iter().map(|o| digit_label(lang, *o)).collect(); + let expected = [ + tr("Default").to_string(), + format!("{} 0123", tr("Arabic")), + format!("{} ٠١٢٣", tr("Hindi")), + format!("{} ۰۱۲۳", tr("Farsi")), + tr("Native").to_string(), + ]; + assert_eq!(shown, expected, "{lang}"); + } } #[test] diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs new file mode 100644 index 000000000..9ccbef1b9 --- /dev/null +++ b/crates/ui-egui/src/shortcut_import.rs @@ -0,0 +1,611 @@ +//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text +//! (Show Set): `window.importShortcuts`. +//! +//! The file lists items under `Product area : ` headers, one per line: +//! ` --- [none defined]` or ` --- : , : `. Item names +//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no +//! shortcut contexts); a type command with no Default entry takes its Text entry. + +use std::collections::{BTreeSet, HashMap}; + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::menus::{self, Item}; + +/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16). +pub const MAX_BYTES: usize = 8 << 20; +/// Entries read per item: a real item has one or two per context. +const MAX_ENTRIES: usize = 64; + +/// One `: ` entry of an item. +struct Entry { + context: String, + keys: String, +} + +struct SetItem { + area: String, + name: String, + entries: Vec, +} + +struct ParsedSet { + name: String, + items: Vec, +} + +/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or +/// by the zero bytes of ASCII text without one). +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20)); + } + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + Ok(match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, true), + [0xFE, 0xFF, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [a, 0, ..] if *a != 0 => utf16(bytes, true), + [0, b, ..] if *b != 0 => utf16(bytes, false), + _ => String::from_utf8_lossy(bytes).into_owned(), + }) +} + +/// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. +pub fn read_file(path: &str) -> Result { + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX); + let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?; + decode(&bytes) +} + +/// `Label : value` with any spacing around the colon, when the line starts with `label`. +fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> { + let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?; + rest.trim_start().strip_prefix(':').map(str::trim) +} + +fn parse(text: &str) -> Result { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut set = ParsedSet { name: String::new(), items: Vec::new() }; + let mut area = String::new(); + let mut header_seen = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if let Some(name) = header(trimmed, "Shortcut Set") { + set.name = name.to_string(); + header_seen = true; + } else if let Some(a) = header(trimmed, "Product area") { + area = a.to_string(); + header_seen = true; + } else if let Some((name, keys)) = trimmed.split_once(" --- ") { + let name = name.trim(); + if name.is_empty() { + continue; + } + set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) }); + } + } + if set.items.is_empty() && !header_seen { + return Err("not a shortcut set: no `Product area` or ` --- ` lines".into()); + } + Ok(set) +} + +/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a +/// piece without `: ` belongs to the entry before it. +fn entries(keys: &str) -> Vec { + let keys = keys.trim(); + if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() { + return Vec::new(); + } + let mut pieces: Vec = Vec::new(); + for piece in keys.split(", ") { + match pieces.last_mut() { + Some(last) if !piece.contains(": ") => { + last.push_str(", "); + last.push_str(piece); + } + _ => pieces.push(piece.to_string()), + } + } + pieces + .into_iter() + .take(MAX_ENTRIES) + .filter_map(|p| { + let (context, keys) = p.split_once(": ")?; + Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() }) + }) + .collect() +} + +/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the +/// command modifier. +fn is_mac_set(set: &ParsedSet) -> bool { + set.items.iter().flat_map(|i| &i.entries).any(|e| { + let k = e.keys.to_lowercase(); + k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥') + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mod { + Command, + Alt, + Shift, + Control, +} + +/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a +/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set. +fn read_keys(keys: &str, mac: bool) -> Result { + const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)]; + const WORDS: &[(&str, Mod)] = &[ + ("command", Mod::Command), + ("cmd", Mod::Command), + ("option", Mod::Alt), + ("opt", Mod::Alt), + ("alt", Mod::Alt), + ("shift", Mod::Shift), + ("control", Mod::Control), + ("ctrl", Mod::Control), + ]; + let mut m = egui::Modifiers::NONE; + let mut set = |md: Mod| match md { + Mod::Command => m.command = true, + Mod::Alt => m.alt = true, + Mod::Shift => m.shift = true, + Mod::Control if mac => m.ctrl = true, + Mod::Control => m.command = true, + }; + let mut rest = keys.trim(); + 'strip: loop { + for (sym, md) in SYMBOLS { + if let Some(r) = rest.strip_prefix(sym) { + set(*md); + rest = r.strip_prefix('+').unwrap_or(r).trim_start(); + continue 'strip; + } + } + for (word, md) in WORDS { + let Some(head) = rest.get(..word.len()) else { continue }; + let Some(after) = rest.get(word.len()..) else { continue }; + // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name. + let mut chars = after.chars(); + if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() { + set(*md); + rest = chars.as_str().trim_start(); + continue 'strip; + } + } + break; + } + if m.ctrl { + return Err("Control-key shortcuts are not supported"); + } + let key = key_named(rest.trim()).ok_or("unknown key")?; + let sc = menus::shortcut_string(m, key); + // Only a string the shortcut dispatcher reads back as these keys is imported. + match menus::parse_shortcut(&sc) { + Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc), + _ => Err("unknown key"), + } +} + +fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool { + a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl +} + +/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive. +fn key_named(name: &str) -> Option { + use egui::Key; + if name.is_empty() { + return None; + } + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase(); + // The numeric keypad: egui doesn't tell its keys apart from the main ones. + let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed); + let key = match n { + "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp, + "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown, + "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft, + "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight, + "pageup" | "pgup" | "⇞" => Key::PageUp, + "pagedown" | "pgdn" | "⇟" => Key::PageDown, + "home" | "↖" => Key::Home, + "end" | "↘" => Key::End, + "esc" | "escape" | "⎋" => Key::Escape, + "tab" | "⇥" => Key::Tab, + "space" | "spacebar" => Key::Space, + "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter, + "backspace" | "⌫" => Key::Backspace, + "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete, + "insert" | "ins" | "help" => Key::Insert, + _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)), + }; + Some(key) +} + +/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and +/// no Show/Hide in front (InDesign names a toggle by what choosing it does now). +fn norm(s: &str) -> String { + let s = s.replace('…', "").replace("...", "").replace('&', ""); + let s = s.split_whitespace().collect::>().join(" ").to_lowercase(); + ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string) +} + +/// DesignCraft's commands by menu path and label. +#[derive(Default)] +struct Index { + /// `menu\nsubmenu\nlabel` → ids, in the menu bar. + path: HashMap>, + /// The same from the engine commands' own menu placement, which a UI command (the one with + /// the dialog) may stand in for in the menu bar. + engine_path: HashMap>, + /// (menu, label) → ids, at any depth of that menu. + in_menu: HashMap<(String, String), BTreeSet>, + /// label → ids, anywhere. + label: HashMap>, +} + +impl Index { + fn build() -> Self { + fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { + for it in items { + match it { + Item::Sub(name, children) => { + path.push(norm(name)); + walk(ix, children, path); + path.pop(); + } + // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one. + Item::Cmd { label, id, params, .. } if params.is_null() => { + let label = norm(label); + let top = path.first().cloned().unwrap_or_default(); + add_path(&mut ix.path, path, &label, id); + ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone()); + ix.label.entry(label).or_default().insert(id.clone()); + } + _ => {} + } + } + } + let mut ix = Index::default(); + for (menu, entries) in menus::menu_tree() { + walk(&mut ix, &entries, &mut vec![norm(menu)]); + } + for c in designcraft_engine::command_specs() { + let label = norm(c.label); + if !c.menu.is_empty() { + let path: Vec = c.menu.iter().map(|m| norm(m)).collect(); + add_path(&mut ix.engine_path, &path, &label, c.id); + } + ix.label.entry(label).or_default().insert(c.id.to_string()); + } + for (id, label, _, _) in menus::UI_COMMANDS { + ix.label.entry(norm(label)).or_default().insert((*id).to_string()); + } + ix + } + + /// The command an item names, trying the most specific match first. Matches by label alone + /// only count when exactly one command has that label. + fn find(&self, area: &str, name: &str) -> Option { + let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); + let segments: Vec = name.split(": ").map(norm).collect(); + let last = segments.last().cloned().unwrap_or_default(); + let area = norm(area); + if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) { + let mut key = menu.to_string(); + for s in &segments { + key.push('\n'); + key.push_str(s); + } + if let Some(id) = one(self.path.get(&key)) { + return Some(id); + } + if segments.len() > 1 + && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" ")))) + { + return Some(id); + } + if let Some(id) = one(self.engine_path.get(&key)) { + return Some(id); + } + if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) { + return Some(id); + } + // Menus are arranged differently: the item's own label, if no other command has it. + return one(self.label.get(&last)); + } + if segments.len() == 1 { one(self.label.get(&last)) } else { None } + } +} + +fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) { + let mut key = path.join("\n"); + key.push('\n'); + key.push_str(label); + map.entry(key).or_default().insert(id.to_string()); +} + +/// What the set says about one DesignCraft command, over every item matched to it. +struct Decision { + id: String, + item: String, + /// The first readable shortcut. + shortcut: Option, + /// Every matched item said `[none defined]`. + all_none: bool, +} + +fn command_label(id: &str) -> String { + menus::ui_label(id) + .map(|(l, _)| l.to_string()) + .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string())) + .unwrap_or_else(|| id.to_string()) +} + +fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> { + menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k)) +} + +/// Imports a set: every matched command takes the set's shortcut (or none), another command +/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + if text.len() > MAX_BYTES { + return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); + } + let set = parse(text)?; + let mac = is_mac_set(&set); + let ix = Index::build(); + let mut decisions: Vec = Vec::new(); + let mut unmatched = Vec::new(); + let mut unmatched_without_shortcut = 0usize; + let mut unreadable = Vec::new(); + for item in &set.items { + let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; + let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); + let Some(id) = ix.find(&item.area, &item.name) else { + if item.entries.is_empty() { + unmatched_without_shortcut += 1; + } else { + let tool = norm(&item.area) == "tools" + && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); + let mut row = json!({"item": shown, "keys": all_keys}); + if tool { + row["reason"] = json!("tool keys are fixed"); + } + unmatched.push(row); + } + continue; + }; + let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); + let text_command = id.starts_with("type.") || id.starts_with("text."); + let mut considered = in_context("Default"); + if considered.is_empty() && text_command { + considered = in_context("Text"); + } + let mut read = None; + let mut why = ""; + for e in &considered { + match read_keys(&e.keys, mac) { + Ok(sc) => { + read = Some(sc); + break; + } + Err(w) => why = w, + } + } + if read.is_none() && !considered.is_empty() { + unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); + } + let pos = match decisions.iter().position(|d| d.id == id) { + Some(p) => p, + None => { + decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); + decisions.len() - 1 + } + }; + let Some(d) = decisions.get_mut(pos) else { continue }; + d.all_none &= item.entries.is_empty(); + if d.shortcut.is_none() + && let Some(sc) = read + { + d.shortcut = Some(sc); + d.item = shown; + } + } + + let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); + let mut imported = 0usize; + let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); + let mut skipped = Vec::new(); + let mut decided: BTreeSet = BTreeSet::new(); + // When two items in the set want the same keys, the later one takes them (the rule shared by + // the Craft apps' importers). + let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new(); + for (i, d) in decisions.iter().enumerate() { + if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) { + winner.insert(keys, i); + } + } + for (i, d) in decisions.iter().enumerate() { + match &d.shortcut { + Some(sc) => { + let Some(keys) = keys_of(sc) else { continue }; + if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); + continue; + } + taken.push((keys, d.id.clone())); + if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), sc.clone()); + } + } + None if d.all_none => { + if menus::default_shortcut(&d.id).is_none() { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), String::new()); + } + } + None => continue, + } + imported += 1; + decided.insert(d.id.clone()); + } + let mut conflicts = Vec::new(); + for (id, sc) in menus::effective_shortcuts(app) { + if decided.contains(&id) { + continue; + } + // A command sharing its default keys with the one taking them (file.new and the New + // Document dialog) keeps them: the dispatcher already prefers the other. + let twin = |by: &str| { + let default = |id: &str| menus::default_shortcut(id).and_then(keys_of); + default(&id).is_some() && default(&id) == default(by) + }; + if let Some(keys) = keys_of(&sc) + && taken.iter().any(|(k, by)| *k == keys && !twin(by)) + { + app.ui.shortcuts.insert(id.clone(), String::new()); + conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); + } + } + let changed: Vec = decisions + .iter() + .filter(|d| decided.contains(&d.id)) + .filter_map(|d| { + let from = before.get(&d.id).cloned().flatten(); + let to = menus::shortcut_of(app, &d.id); + (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to})) + }) + .collect(); + Ok(json!({ + "set": set.name, + "imported": imported, + "changed": changed, + "conflictsCleared": conflicts, + "skipped": skipped, + "unmatched": unmatched, + "unmatchedWithoutShortcut": unmatched_without_shortcut, + "unreadable": unreadable, + })) +} + +/// One line for the Keyboard Shortcuts dialog about an import's report. +pub fn summary(lang: &str, report: &Value) -> String { + let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string(); + let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string(); + crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") + .replace("{count}", &n("imported")) + .replace("{set}", report["set"].as_str().unwrap_or_default()) + .replace("{unmatched}", &len("unmatched")) + .replace("{unreadable}", &len("unreadable")) +} + +/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and +/// returns the summary; in a browser the chosen file arrives later through the import inbox. +pub fn pick_and_import(app: &mut DesignApp) -> Option { + if let Some(pick) = app.services.pick_open.as_mut() { + let path = pick("shortcuts")?; + return Some(match app.run("window.importShortcuts", json!({"path": path})) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }); + } + let request = app.import_request("shortcuts"); + if let Some(open) = app.services.open_async.as_mut() { + open(request); + } + None +} + +/// A set file chosen in the browser: import it and say how it went in the open Keyboard +/// Shortcuts dialog, or the status bar. +pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { + let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }; + match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") { + Some(d) => { + d.fields.insert("message".into(), json!(msg)); + } + None => app.status(msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) + } + + #[test] + fn imports_a_windows_set_and_reports_what_it_could_not_use() { + let mut app = app(); + // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling. + menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap(); + let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\ + Product area : Edit Menu\r\n\ + \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tFrobnicate Everything --- Default: Ctrl+F12\r\n\ + \tNever Heard Of It --- [none defined]\r\n\ + Product area : File Menu\r\n\ + \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ + \tSave --- Default: Ctrl+Hyperspace\r\n\ + \tRevert --- Default: Shift+Ctrl+F9\r\n\ + \tSave As... --- Default: Shift+Ctrl+F9\r\n\ + garbage line without a separator\r\n"; + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); + assert_eq!(r["set"], "My Set"); + // A submenu item, with Ctrl as the command modifier. + assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5")); + // `[none defined]` in every occurrence clears a matched command. + assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None); + // The command that held the imported keys loses them. + assert_eq!(menus::shortcut_of(&app, "app.palette"), None); + assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}"); + // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut. + assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}])); + assert_eq!(r["unmatchedWithoutShortcut"], 1); + assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); + assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); + // Two items want the same keys: the later one takes them. + assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9")); + assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}"); + assert_eq!(r["imported"], 4); + // Reset Shortcuts is the way back. + menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap(); + assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return")); + } + + #[test] + fn hostile_input_returns_a_report_or_an_error() { + let mut app = app(); + for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] { + let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap(); + } + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err()); + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err()); + // UTF-16 with a BOM, and Mac modifier spellings. + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes)); + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap(); + assert_eq!(r["set"], "Mac"); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W")); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..2d07c9629 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,2 @@ +#[cfg(not(target_arch = "wasm32"))] +mod native_job_lifetime_control; diff --git a/crates/ui-egui/tests/native_job_lifetime_control.rs b/crates/ui-egui/tests/it/native_job_lifetime_control.rs similarity index 98% rename from crates/ui-egui/tests/native_job_lifetime_control.rs rename to crates/ui-egui/tests/it/native_job_lifetime_control.rs index 2cf82e5bd..b7fa87214 100644 --- a/crates/ui-egui/tests/native_job_lifetime_control.rs +++ b/crates/ui-egui/tests/it/native_job_lifetime_control.rs @@ -1,5 +1,4 @@ //! Own a real native Job across last close. This does not run the asynchronous worker. -#![cfg(not(target_arch = "wasm32"))] use std::sync::Arc; diff --git a/docs/architecture.md b/docs/architecture.md index a90b75df3..6e0c4c66b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,3 +113,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | + +PDF parsing and placed-page rasterization use the pinned `craft-pdf` reader. DesignCraft retains document, placement, layer and export policy; dependency checks and bounds are described in [pdf-backend.md](pdf-backend.md). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 30a4448ee..aa266aec3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in | PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | | Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | | Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | -| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) | | User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | | Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index fcc6afeaa..4cbc985d3 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,9 +1,12 @@ -# PDF writing boundary +# Shared PDF backend -DesignCraft pins `craft-pdf` and its patched Krilla 0.8.2 backend to craft-libs revision -`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. The `write-pdf` capability is required for -placed PDF pages and book merging. The ordinary reader dependencies, `craft-ui` and -`craft-print` revisions remain unchanged by this writer migration. +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to +craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions +remain unchanged. The reader migration is reviewed on top of the separate writer change. + +## Writing + +The `write-pdf` capability is required for placed PDF pages and book merging. The shared library supplies fallible font copying/preparation, metadata setup, version and archival configuration, final serialization and a native unwind guard. DesignCraft retains @@ -32,3 +35,30 @@ Tests cover metadata key presence, hostile page selection, successful PDF export subprocess recovery for all three export entry points. Native/WASM export comparisons and independent PDF parsing/rendering supplement the repository gates. Physical printer output, Windows/Linux runtime and PDF/A/PDF/X certification remain separate acceptance work. + +## Reading + +Placed-PDF geometry, layers and raster output use `craft-pdf` with its explicit `raster` +feature. The facade and four patched Hayro crates are pinned to one immutable craft-libs +revision. The backend source was transferred from PdfCraft revision +`7437bde378f43856eada40af25b983812e615e8a`; the per-crate `vendor/SOURCE.json` manifests and `vendor/README.craft-reader.md` +record the transfer and subsequent craft-libs patches. Asset provenance and hashes +are recorded separately in ASSETS.md. +`cargo xtask pdf-deps` checks unique resolved packages, exact versions and sources. + +DesignCraft retains placement, layer-name policy, document state and the application render +cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and +UserUnit transform as raster output, including fractional placed-PDF dimensions. +Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete +content rather than saving an unchanged or incomplete placed page. Shared diagnostics only +report known backend warnings; they do not certify all PDF fidelity. + +DesignCraft uses a 512 MiB reader input bound, checked before parsing or copying. +Raster output has shared geometry/allocation bounds. Native unwind guards turn escaped +backend panics into errors; WASM aborts and process-wide allocation failures are not recoverable. +Application fonts, image assets, editing and export presets remain app-owned. Existing +craft-ui/craft-print versions are preserved. The reader migration builds on the independently qualified writer change. + +The shared commit must be published before a fresh public checkout can resolve these pins. +No sibling checkout is required. Licences and existing backend asset notices are recorded in +NOTICE, ASSETS.md and licenses/pdf-backend. The backend's removed LAB.icc is not restored. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 619993f60..a38352b64 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. @@ -283,7 +283,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. @@ -416,7 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). -- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. +- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`. - Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..b66fb585c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux. | Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | | IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) | | Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope | -| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | | +| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied | | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | diff --git a/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE b/licenses/pdf-backend/hayro-cmap/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/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/licenses/pdf-backend/hayro-cmap/LICENSE-MIT b/licenses/pdf-backend/hayro-cmap/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/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/licenses/pdf-backend/hayro-cmap/LICENSE.txt b/licenses/pdf-backend/hayro-cmap/LICENSE.txt new file mode 100644 index 000000000..2552cc990 --- /dev/null +++ b/licenses/pdf-backend/hayro-cmap/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/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE b/licenses/pdf-backend/hayro-interpret/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/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/licenses/pdf-backend/hayro-interpret/LICENSE-MIT b/licenses/pdf-backend/hayro-interpret/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/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/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt new file mode 100644 index 000000000..550e190e5 --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see +http://creativecommons.org/publicdomain/zero/1.0/ \ No newline at end of file diff --git a/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT new file mode 100644 index 000000000..8b4ed6ddd --- /dev/null +++ b/licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT @@ -0,0 +1,27 @@ +// Copyright 2014 PDFium Authors. 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 Google Inc. 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 +// OWNER 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/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE b/licenses/pdf-backend/hayro-jbig2/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-jbig2/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/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT b/licenses/pdf-backend/hayro-jbig2/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-jbig2/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/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE b/licenses/pdf-backend/hayro-syntax/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro-syntax/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/licenses/pdf-backend/hayro-syntax/LICENSE-MIT b/licenses/pdf-backend/hayro-syntax/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro-syntax/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/licenses/pdf-backend/hayro/LICENSE-APACHE b/licenses/pdf-backend/hayro/LICENSE-APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/licenses/pdf-backend/hayro/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/licenses/pdf-backend/hayro/LICENSE-MIT b/licenses/pdf-backend/hayro/LICENSE-MIT new file mode 100644 index 000000000..ae3742de4 --- /dev/null +++ b/licenses/pdf-backend/hayro/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/packaging/linux/package.sh b/packaging/linux/package.sh index 2d7b80ee8..12ad8e0f2 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -148,7 +148,7 @@ if has appimage; then TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then download="$TOOL.download" - curl -fsSL -o "$download" \ + curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - chmod 755 "$download" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..f1095122d --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_designcraft +sonar.organization=storytold diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c89b7e8d7..5000860cc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -12,7 +12,7 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask @@ -20,12 +20,14 @@ usage: cargo xtask commands: assets check that every icon/image/font/asset is attributed in ASSETS.md layers enforce the crate dependency layering (plan/architecture.md §3) - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) + pdf-deps verify the pinned PDF backend, including Krilla dependencies + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call) + test workspace tests (cargo nextest when installed, else cargo test), then doctests + ci fmt --check, pdf-deps, clippy -D warnings, test, doctest, assets, layers, wasm + (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) - pdf-deps verify the pinned shared PDF writer and patched Krilla parity recompute the InDesign feature-parity summary in docs/parity-checklist.md ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) @@ -37,11 +39,12 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), + Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), - Some("pdf-deps") => pdf_deps::run(&root()), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), @@ -134,21 +137,51 @@ fn wasm_set() -> Result, String> { .collect()) } +/// One `cargo check` for the whole set: each crate is checked once, and +/// `--keep-going` still reports every failing crate. Features unify across the +/// set, so each crate is checked with the union of the features the set enables. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; - let mut results = Vec::new(); + if set.is_empty() { + return Err("no wasm-safe crates found in the workspace metadata".into()); + } + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for pkg in &set { - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); - results.push((pkg.clone(), ok)); + c.args(["-p", pkg]); } - println!("\nwasm32-unknown-unknown check:"); - for (p, ok) in &results { - println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); - } - let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } + run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))?; + println!("\nwasm32-unknown-unknown check: ok ({} crates: {})", set.len(), set.join(", ")); + Ok(()) +} + +/// Test targets for the test build. Examples are left out so they aren't +/// linked; clippy `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") } fn cmd_ci() -> Result<(), String> { @@ -162,6 +195,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo fmt --all -- --check") }), ), + ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ( "clippy", Box::new(|| { @@ -170,15 +204,8 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo clippy --workspace --all-targets -- -D warnings") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), - ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 34868f407..6c9c4cffe 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -1,4 +1,4 @@ -//! Check the shared writer source, including transitive users such as krilla-svg. +//! Check the shared reader source, including the parser reached through Krilla. use std::path::Path; use std::process::Command; @@ -6,7 +6,14 @@ use serde_json::Value; const REPO: &str = "https://github.com/aiongg/craft-libs"; const REV: &str = "d30598dcb4b389561c485b97d4a97a0c86bc36e4"; -const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")]; +const PACKAGES: &[(&str, &str)] = &[ + ("craft-pdf", "0.1.0"), + ("krilla", "0.8.2"), + ("hayro", "0.7.1"), + ("hayro-interpret", "0.7.0"), + ("hayro-syntax", "0.7.2"), + ("hayro-jbig2", "0.3.0"), +]; pub fn run(root: &Path) -> Result<(), String> { let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) @@ -19,7 +26,7 @@ pub fn run(root: &Path) -> Result<(), String> { } let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; validate(&metadata)?; - println!("PDF writer: facade and patched Krilla resolve once to {REV}"); + println!("PDF backend: facade, Krilla and Hayro resolve once to {REV}"); Ok(()) } @@ -35,6 +42,15 @@ fn validate(metadata: &Value) -> Result<(), String> { return Err(format!("{name}: expected {version} from {source}; got {package}")); } } + let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?; + let facade = packages.iter().find(|p| p["name"] == "craft-pdf").ok_or("craft-pdf: missing package")?; + let node = nodes.iter().find(|n| n["id"] == facade["id"]).ok_or("craft-pdf: missing resolved node")?; + let features = node["features"].as_array().ok_or("craft-pdf: missing features")?; + for required in ["read", "interpret", "raster", "write", "write-pdf"] { + if !features.iter().any(|f| f.as_str() == Some(required)) { + return Err(format!("craft-pdf: missing required {required} feature")); + } + } Ok(()) } @@ -44,17 +60,17 @@ mod tests { use serde_json::json; fn metadata() -> Value { - json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"name":name,"version":version, - "source":format!("git+{REPO}?rev={REV}#{REV}")})).collect::>()}) + json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"id":name,"name":name,"version":version, + "source":format!("git+{REPO}?rev={REV}#{REV}")})).collect::>(), "resolve": {"nodes": [{"id": "craft-pdf", "features": ["read", "interpret", "raster", "write", "write-pdf"]}]}}) } #[test] - fn accepts_one_pinned_facade_and_writer() { + fn accepts_one_pinned_facade_and_reader() { assert!(validate(&metadata()).is_ok()); } #[test] - fn rejects_unpatched_and_local_writer_sources() { + fn rejects_unpatched_and_local_reader_sources() { for source in [Value::Null, json!("registry+https://github.com/rust-lang/crates.io-index")] { let mut m = metadata(); m["packages"][1]["source"] = source; @@ -64,7 +80,7 @@ mod tests { #[test] fn rejects_missing_duplicate_and_wrong_version_packages() { - for index in [0, 1] { + for index in 0..PACKAGES.len() { let mut m = metadata(); m["packages"][index]["version"] = json!("99.0.0"); assert!(validate(&m).is_err()); @@ -78,6 +94,18 @@ mod tests { } } + #[test] + fn rejects_missing_reader_or_writer_features() { + for index in 0..5 { + let mut m = metadata(); + m["resolve"]["nodes"][0]["features"].as_array_mut().unwrap().remove(index); + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + m["resolve"] = Value::Null; + assert!(validate(&m).is_err()); + } + #[test] fn rejects_facade_revision_drift_and_malformed_metadata() { let mut m = metadata();